Transaction

10A8D6E35E3AD2…937522B05023

Block 271,992 · index 0 · indexed

Summary

Hash
10A8D6E35E3AD2F4826BB00270D1F3E18B215B56B3E475D56496937522B05023
Block
271,992
Size
102406 bytes
Gas used
124,318,791 / 277,163,600
Fee
831490ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/grants/v021 arguments
Attached funds
41000000ugnot

Arguments · 21

  1. #1grants
  2. #2README.md
  3. #3# grants A grant program governed by a member set: anyone may ask the board for money, the members vote in the open, and the money leaves in tranches that each have to be earned by producing a proof the members accept. The package is **pure**. It holds no coins, calls no banker, reads no chain state, and imports nothing from `chain`: the caller supplies the acting address, the block height and the current treasury balance, and the caller executes the transfer. That is what makes the whole state machine unit-testable without a node, and what makes the realm on top thin enough to read in one screen. Live: [`r/moul/grant/v0`](/r/moul/grant/v0), a personal board. A multi-member program is another realm of the same size over this same package. ## Three layers | Type | What it owns | |---|---| | `Board` | members, requests, ballots, proofs. Every rule and every tally. | | `Program` | a `Board` plus the money around it: donations in, payments out, the denom. | | `Renderer` | the three markdown pages, configured by a struct literal. | A realm holds one `Program`, builds a `Renderer` inside `Render`, and does nothing else but turn callers into addresses and execute the `Payment` it is handed back. ## The lifecycle ``` Submit ──▶ Pending ──vote──▶ Approved ──┬─▶ SubmitProof ──▶ Review ──┬─▶ Released ─┐ │ │ │ │ │ │ └─▶ Refused ──┘ │ │ (try again) ├──vote──▶ Rejected └─▶ every milestone released ──▶ Completed └──Withdraw──▶ Withdrawn ``` A `Request` is a title, a body, and an ordered list of `Milestone`s, each with its own amount. **Approving a request pays nothing**: it only makes the first milestone claimable. To get a tranche the applicant submits a `Proof` (a URL, a hash, or plain text) and the members review *that specific proof*. A refused proof does not kill the grant, it closes one `Attempt`; the applicant submits another. Every attempt, accepted or not, stays on the record with the ballots that decided it. ## Who may vote, and how many it takes Every member, once, per decision. There is no changing your mind: that is the price of every ballot being a permanent public statement, stored with its voter, its reason and the height it was cast at. **The party a decision is about is excluded.** An applicant does not vote on their own grant, and the subject of a membership change does not vote on their own membership. The bar is a majority of the addresses actually *eligible*, recomputed on every ballot, so a member who applies shrinks the room rather than packing it, and a board that grows mid-vote raises its own bar. A **one-member board is a legitimate configuration**, and its majority is one: that is what a personal program looks like. It still cannot self-grant, because a sole member applying leaves zero eligible voters and `Majority` returns an unreachable 1 rather than 0. `Standing` counts only ballots from addresses that are members **right now**; decisions use it. `Request.Tally` counts the raw record. The two differ exactly when a voter has since been removed from the board: their ballot stays readable and stops carrying weight. ## Asking for someone else `SubmitFor(applicant, beneficiary, reason, …)` files a request whose tranches pay an address other than the one that filed it. `Submit` is the same call with the two addresses equal. The split is not a convenience. **An account with nothing in it cannot pay the gas to ask for its first coins**, so on a board whose purpose is to fund empty accounts, someone else filing is the only path that works. Two rules follow from that, and both are in the library: - **The reason is required** when the payee is someone else. Nothing verifies it; it is a claim by the applicant, and the renderer prints it under its own heading so a member can check it before voting. - **Both addresses are excluded from voting**, on the request and on every proof. A member paid by a request a friend filed is the same conflict of interest with one address in between. `Request.Excludes` is the predicate, `Board.Eligible` recounts over it, and the majority moves with it. Either the applicant or the beneficiary may `SubmitProof`: the payee may not be able to transact until the first tranche lands, and once they can, they have to be able to show their own work. `Request.Payee()` is who a released tranche pays, and it is what the `Payment` handed back to the realm carries. ## Membership is a request like any other `SubmitMemberChange` files a `KindMember` request. It asks for no money, and when it carries it executes immediately, going straight to `Completed`. Only a member may file one: opening a grant board's own composition to anyone with a keypair is how it gets captured. The last member cannot be removed. ## The money: `Program` ```go p := grants.NewProgram("ugnot", alice, bob, carol) p.Fund(donor, 5000, height) // put a name on a transfer that already happened r, _ := p.Apply(dave, "Port the thing", "why it matters", "design:100,ship:400", height) p.Board.Vote(alice, r.ID, true, "cheap for what it tells us", height) p.Board.Vote(bob, r.ID, true, "agreed", height) // majority of three: approved p.Board.SubmitProof(dave, r.ID, 0, grants.Proof{Kind: "url", Ref: "https://…", Height: height}) p.Review(alice, r.ID, 0, true, "merged, I reviewed it", height, balance) out, pay, err := p.Review(carol, r.ID, 0, true, "confirmed", height, balance) if pay != nil { // out == grants.Accepted. Send pay.Amount to pay.To; it is already on the ledger. } ``` **The treasury is not escrowed.** `Committed()` is what approved-but-unreleased milestones add up to, `Available(balance)` is the balance minus that, and it **can go negative**. That is deliberate: a board that can only approve what it already holds cannot approve anything before a donor shows up. The cost is that a release can come due against an empty treasury, so `Program.Review` takes the balance and checks, *before recording anything*, whether this verdict would release a tranche it cannot cover. If so it returns `ErrUnderfunded` and changes nothing: no ballot, no release, no payment. A **refusal** is always free to record, because it costs the treasury nothing. `Board.Decides` and `Board.ReviewDecides` expose the same preview for anything else that needs to check a precondition it cannot roll back. ## The pages: `Renderer` `Renderer` serves the board at `""`, one request at `request/<id>`, and the money trail at `ledger`. Everything program-specific is a field (`Title`, `Intro`, `Notes`, `Path`, `Link`, `Treasury`, `Balance`, `Footer`, and a `Note func(*Request) string` for per-request callouts), so a realm's whole `Render` is a struct literal. Build it **inside** `Render`, not in a realm global: `Note` is a func, and `Balance` has to be read fresh on every call anyway. The five `ExampleRenderer*` tests pin every page of a board mid-flight, so a change to any rule shows up as a diff in the markdown. **Escaping happens here, once.** Everything a caller typed goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) and [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0) on its way to the page, so a realm neither repeats it nor pre-escapes (escaping twice shows the backslashes). The bar differs by slot on purpose: a one-line slot (a title, a milestone name, a reason on a ballot) keeps nothing a caller typed as markup, while a prose slot (the body of an application, the note on a proof) goes through `sanitize.Block`, which preserves inline links and emphasis because a grant application whose link to the merged PR renders as literal text is a worse page. What `Block` still kills is everything structural, so a paragraph cannot leave its paragraph. The realm's own `Title`, `Intro`, `Notes` and `Footer` are chrome, written by whoever deployed it, and are emitted as-is. `TestEveryCallerSuppliedStringIsEscaped` drives one board through every caller-controlled slot and asserts no structural line of any page carries a live link, an image or a gnoweb tag. It asserts the dangerous sequence is dead, never the exact escaped bytes: those belong to the sanitizer and change when it changes. ## What this deliberately does not do No weights, no delegation, no quadratic anything, no deadline. A request with no majority either way stays `Pending` until someone breaks the tie or the applicant withdraws it. Those are all reasonable things to build on top; none is needed to show the shape. <!-- 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:** ![gno.land/p/moul/grants/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/grants/v0/deps.png) > ⚠️ **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 -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/grants/v0" gno = "0.9"
  6. #6grants.gno
  7. #7// Package grants is a grant board governed by a member set: anyone may ask // the board for money, the members vote in the open, and the money leaves in // tranches that each have to be earned by producing a proof the members // accept. // // The package is pure. It holds no coins, reads no chain state and imports // nothing from `chain`: the caller supplies the acting address and the block // height, and the caller performs the transfer. That is what makes the whole // state machine unit-testable without a node, and it is what lets the same // board be driven by a realm, by a test, or by a different payment rail. // Live board: gno.land/r/moul/grant/v0. // // # The shape of a grant // // A Request is a title, a body, and an ordered list of Milestones, each with // its own amount. Approving a request does not pay anything: it only makes the // first milestone claimable. To get a tranche the applicant submits a Proof // (a URL, a hash, or plain text) and the members review that specific proof. // A rejected proof does not kill the grant, it just closes one Attempt; the // applicant submits another. Every attempt, accepted or not, stays on the // record with the ballots that decided it. // // # Asking for someone else // // A request has an applicant, who files it and answers for it, and a // beneficiary, who gets paid. SubmitFor separates the two and takes a reason // for the detour; Submit is the common case where they are the same address. // // The split is not a convenience. An account with nothing in it cannot pay the // gas to ask for its first coins, so on a board whose whole purpose is to fund // empty accounts, "someone else files it" is the ONLY path that works. Both // addresses are then barred from voting on the request, and either of them may // submit a proof, for the same reason: the payee may not be able to transact // at all until the first tranche lands. // // # Who may vote // // Every member, once, per decision. The party the decision is about is // excluded: the applicant does not vote on their own grant, and the subject of // a membership change does not vote on their own membership. The bar is a // majority of the addresses that are actually eligible, recomputed on each // ballot, so a board that grows mid-vote raises its own bar. // // # What this deliberately does not do // // No weights, no delegation, no quadratic anything, no deadline. A request // with no majority either way simply stays Pending until someone breaks the // tie or the applicant withdraws it. Those are all reasonable things to add on // top; none of them is needed to show the shape. package grants import ( "errors" "strconv" "gno.land/p/nt/avl/v0" ) // Errors returned by the mutators. They are sentinels so a realm can map them // onto its own messages, or simply panic with them. var ( ErrNotMember = errors.New("grants: not a board member") ErrNotApplicant = errors.New("grants: only the applicant may do this") ErrNoRequest = errors.New("grants: no such request") ErrNoMilestone = errors.New("grants: no such milestone") ErrNotPending = errors.New("grants: the request is not pending") ErrNotApproved = errors.New("grants: the request is not approved") ErrAlreadyVoted = errors.New("grants: this member already voted") ErrConflict = errors.New("grants: the party a decision is about may not vote on it") ErrOutOfOrder = errors.New("grants: milestones are claimed in order") ErrProofPending = errors.New("grants: a proof is already under review") ErrNoProof = errors.New("grants: no proof is under review") ErrBadTitle = errors.New("grants: title must be 1 to 100 characters") ErrBadBody = errors.New("grants: body must be at most 2000 characters") ErrBadMilestones = errors.New("grants: a grant needs 1 to 10 milestones") ErrBadAmount = errors.New("grants: every milestone must ask for a positive amount") ErrBadProof = errors.New("grants: proof kind must be url, hash or text, with a reference") ErrBadReason = errors.New("grants: reason must be at most 300 characters") ErrNeedReason = errors.New("grants: filing for someone else needs a reason") ErrBadAddress = errors.New("grants: invalid address") ErrIsMember = errors.New("grants: already a board member") ErrLastMember = errors.New("grants: the last member cannot be removed") ErrNothingSent = errors.New("grants: a donation must be a positive amount") ErrUnderfunded = errors.New("grants: the treasury cannot cover this tranche") ErrBadSpec = errors.New("grants: milestones must read \"title:amount,title:amount\"") ) // Limits every input is checked against. Exported so a UI can pre-validate. const ( MaxTitle = 100 MaxBody = 2000 MaxReason = 300 MaxRef = 300 MaxNote = 500 MaxMilestones = 10 ) // Status is where a request sits in its lifecycle. type Status int const ( Pending Status = iota // open for member ballots Approved // carried; milestones can be earned Rejected // a majority voted no Completed // every milestone released Withdrawn // the applicant pulled it before a decision ) func (s Status) String() string { switch s { case Pending: return "pending" case Approved: return "approved" case Rejected: return "rejected" case Completed: return "completed" case Withdrawn: return "withdrawn" } return "unknown" } // Kind says what carrying a request actually does. type Kind int const ( KindGrant Kind = iota // pays out, milestone by milestone KindMember // adds or removes a board member, immediately ) func (k Kind) String() string { if k == KindMember { return "membership" } return "grant" } // Outcome is the result of one proof attempt. type Outcome int const ( UnderReview Outcome = iota // members are still voting on this proof Accepted // the tranche is earned Refused // this proof did not convince; another may ) func (o Outcome) String() string { switch o { case Accepted: return "accepted" case Refused: return "refused" } return "under review" } // Ballot is one member's vote on one decision, with the reason they gave. // Reasons are not optional decoration: they are the only part of a vote that // tells an applicant what to fix. type Ballot struct { Voter address Approve bool Reason string Height int64 } // Proof is the evidence an applicant offers for one milestone. Kind is "url", // "hash" or "text"; the board decides what it is worth. type Proof struct { Kind string Ref string Note string Height int64 } // Attempt is one proof and the review it got. A milestone keeps every attempt, // so a refused proof and the reasons it was refused stay readable forever. type Attempt struct { Proof Proof Reviews []Ballot Outcome Outcome ClosedAt int64 } // Milestone is one tranche of a grant: what has to be shown, and what it pays. type Milestone struct { Title string Amount int64 // in the smallest unit of whatever the realm pays in Attempts []*Attempt Released bool ReleasedAt int64 } // Current returns the attempt still under review, or nil. func (m *Milestone) Current() *Attempt { if len(m.Attempts) == 0 { return nil } last := m.Attempts[len(m.Attempts)-1] if last.Outcome == UnderReview { return last } return nil } // Request is a grant application or a membership change. type Request struct { ID int Kind Kind Applicant address // who filed it and answers for it Beneficiary address // who gets paid; equal to Applicant on a self request Reason string // why the applicant is asking on someone else's behalf Title string Body string Milestones []*Milestone Subject address // KindMember only: the address being added or removed Add bool // KindMember only: true adds, false removes Votes []Ballot Status Status CreatedAt int64 DecidedAt int64 } // OnBehalf reports whether this grant was filed by one address for another. func (r *Request) OnBehalf() bool { return r.Kind == KindGrant && r.Beneficiary != r.Applicant } // Payee is the address a released tranche pays. It is the beneficiary, which // is the applicant unless the request was filed for someone else. func (r *Request) Payee() address { if r.Beneficiary.IsValid() { return r.Beneficiary } return r.Applicant } // Total is the sum of every milestone, released or not. func (r *Request) Total() int64 { var n int64 for _, m := range r.Milestones { n += m.Amount } return n } // Paid is what the milestones released so far add up to. func (r *Request) Paid() int64 { var n int64 for _, m := range r.Milestones { if m.Released { n += m.Amount } } return n } // Outstanding is what an approved request can still cost the treasury. It is // zero for anything not approved, which is what makes it safe to sum across // the board as the committed amount. func (r *Request) Outstanding() int64 { if r.Status != Approved { return 0 } return r.Total() - r.Paid() } // Next is the index of the first unreleased milestone, or -1 when the grant is // fully paid. Milestones are earned in order. func (r *Request) Next() int { for i, m := range r.Milestones { if !m.Released { return i } } return -1 } // Tally counts the ballots cast on the request itself. func (r *Request) Tally() (yes, no int) { return count(r.Votes) } // BallotOf returns the member's ballot on the request, or nil. func (r *Request) BallotOf(voter address) *Ballot { return find(r.Votes, voter) } // Excludes reports whether addr is barred from voting on this request because // the decision is about them. // // A grant excludes BOTH parties, not just the one who typed the transaction: // letting a member be paid by a request a friend filed for them is the same // conflict with one more step in it. func (r *Request) Excludes(addr address) bool { if r.Kind == KindMember { return addr == r.Subject } return addr == r.Applicant || addr == r.Payee() } func count(bs []Ballot) (yes, no int) { for _, b := range bs { if b.Approve { yes++ } else { no++ } } return yes, no } func find(bs []Ballot, voter address) *Ballot { for i := range bs { if bs[i].Voter == voter { return &bs[i] } } return nil } // Board is the whole state of one grant program: who decides, and what has // been asked of them. type Board struct { members *avl.Tree // address string -> int64 join height requests *avl.Tree // zero-padded id -> *Request nextID int } // New returns a board whose founding members are the given addresses, all // recorded as having joined at height 0. Duplicates and invalid addresses are // skipped; a board with no members accepts no decisions until one is added, so // callers normally pass at least one. func New(founders ...address) *Board { b := &Board{members: avl.NewTree(), requests: avl.NewTree(), nextID: 1} for _, f := range founders { if f.IsValid() { b.members.Set(f.String(), int64(0)) } } return b } // IsMember reports whether addr sits on the board. func (b *Board) IsMember(addr address) bool { return b.members.Has(addr.String()) } // MemberCount is the number of addresses on the board. func (b *Board) MemberCount() int { return b.members.Size() } // Members lists the board in address order, which is stable across calls. func (b *Board) Members() []address { out := make([]address, 0, b.members.Size()) b.members.Iterate("", "", func(k string, _ any) bool { out = append(out, address(k)) return false }) return out } // JoinedAt returns the height a member joined, or -1 for a non-member. func (b *Board) JoinedAt(addr address) int64 { v := b.members.Get(addr.String()) if v == nil { return -1 } return v.(int64) } // Eligible is how many members may vote on a request: everyone but the parties // the decision is about. func (b *Board) Eligible(r *Request) int { n := 0 b.members.Iterate("", "", func(k string, _ any) bool { if !r.Excludes(address(k)) { n++ } return false }) return n } // Majority is the number of concurring ballots a decision on r needs. It is // recomputed on every ballot, so a board that changes size mid-vote moves its // own bar rather than freezing a stale one. func (b *Board) Majority(r *Request) int { n := b.Eligible(r) if n <= 0 { return 1 // an unreachable bar beats a bar of zero } return n/2 + 1 } // Get returns a request by id, or nil. func (b *Board) Get(id int) *Request { v := b.requests.Get(idKey(id)) if v == nil { return nil } return v.(*Request) } // Size is the number of requests ever filed. func (b *Board) Size() int { return b.requests.Size() } // List returns every request in id order. func (b *Board) List() []*Request { out := make([]*Request, 0, b.requests.Size()) b.requests.Iterate("", "", func(_ string, v any) bool { out = append(out, v.(*Request)) return false }) return out } // Committed is what the treasury still owes on approved grants. A realm that // keeps its balance at or above this number can always pay a released tranche. func (b *Board) Committed() int64 { var n int64 b.requests.Iterate("", "", func(_ string, v any) bool { n += v.(*Request).Outstanding() return false }) return n } // Disbursed is everything the board has ever released. func (b *Board) Disbursed() int64 { var n int64 b.requests.Iterate("", "", func(_ string, v any) bool { n += v.(*Request).Paid() return false }) return n } // idKey pads an id so avl iteration is numeric, not lexicographic: unpadded // keys sort "1","10","2" and every listing would lose its order past nine // entries. ufmt has no width flags in gno, so the padding is by hand. func idKey(id int) string { s := strconv.Itoa(id) for len(s) < 8 { s = "0" + s } return s }
  8. #8grants_test.gno
  9. #9package grants import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") // member bob = testutils.TestAddress("bob") // member carol = testutils.TestAddress("carol") // member dave = testutils.TestAddress("dave") // applicant, not a member mallory = testutils.TestAddress("mallory") // nobody ) // board3 is the fixture most tests want: three members, so a majority of the // eligible set is two whenever the applicant is an outsider. func board3() *Board { return New(alice, bob, carol) } // submitOne files a two-milestone grant from dave, the outsider applicant. func submitOne(t *testing.T, b *Board) *Request { t.Helper() r, err := b.Submit(dave, "Port the thing", "with tests", []*Milestone{NewMilestone("design", 100), NewMilestone("ship", 400)}, 10) urequire.NoError(t, err) return r } func TestNewSeedsFoundersAtHeightZero(t *testing.T) { b := board3() uassert.Equal(t, 3, b.MemberCount()) for _, m := range []address{alice, bob, carol} { uassert.True(t, b.IsMember(m), m.String()+" should be a founder") uassert.Equal(t, int64(0), b.JoinedAt(m)) } uassert.False(t, b.IsMember(dave)) uassert.Equal(t, int64(-1), b.JoinedAt(dave)) // Members is address-ordered, and the order is what avl gives, not // insertion order. Only the stability matters, so compare two calls. first, second := b.Members(), b.Members() urequire.Equal(t, 3, len(first)) for i := range first { uassert.Equal(t, first[i].String(), second[i].String()) } } func TestNewSkipsInvalidFounders(t *testing.T) { b := New(alice, address(""), alice) uassert.Equal(t, 1, b.MemberCount(), "empty address dropped, duplicate collapsed") } func TestSubmitValidation(t *testing.T) { long := "" for i := 0; i < MaxTitle+1; i++ { long += "x" } body := "" for i := 0; i < MaxBody+1; i++ { body += "y" } many := make([]*Milestone, MaxMilestones+1) for i := range many { many[i] = NewMilestone("m", 1) } cases := []struct { name string title string body string ms []*Milestone want error }{ {"ok", "t", "b", []*Milestone{NewMilestone("m", 1)}, nil}, {"empty title", "", "b", []*Milestone{NewMilestone("m", 1)}, ErrBadTitle}, {"long title", long, "b", []*Milestone{NewMilestone("m", 1)}, ErrBadTitle}, {"long body", "t", body, []*Milestone{NewMilestone("m", 1)}, ErrBadBody}, {"no milestone", "t", "b", nil, ErrBadMilestones}, {"too many milestones", "t", "b", many, ErrBadMilestones}, {"zero amount", "t", "b", []*Milestone{NewMilestone("m", 0)}, ErrBadAmount}, {"negative amount", "t", "b", []*Milestone{NewMilestone("m", -1)}, ErrBadAmount}, {"untitled milestone", "t", "b", []*Milestone{NewMilestone("", 1)}, ErrBadTitle}, } for _, tc := range cases { _, err := board3().Submit(dave, tc.title, tc.body, tc.ms, 1) if tc.want == nil { uassert.NoError(t, err, tc.name) continue } uassert.ErrorIs(t, err, tc.want, tc.name) } _, err := board3().Submit(address(""), "t", "b", []*Milestone{NewMilestone("m", 1)}, 1) uassert.ErrorIs(t, err, ErrBadAddress, "invalid applicant") } func TestRequestArithmetic(t *testing.T) { b := board3() r := submitOne(t, b) uassert.Equal(t, int64(500), r.Total()) uassert.Equal(t, int64(0), r.Paid()) uassert.Equal(t, int64(0), r.Outstanding(), "a pending grant commits nothing") uassert.Equal(t, 0, r.Next()) uassert.Equal(t, "pending", r.Status.String()) uassert.Equal(t, "grant", r.Kind.String()) uassert.True(t, r.Excludes(dave), "the applicant never votes on their own grant") uassert.False(t, r.Excludes(alice)) uassert.Equal(t, dave.String(), r.Payee().String(), "a self request pays its applicant") uassert.False(t, r.OnBehalf()) uassert.Equal(t, 1, r.ID) uassert.Equal(t, r.ID, b.Get(1).ID) uassert.True(t, b.Get(42) == nil, "unknown id") } func TestMajorityCountsOnlyEligibleMembers(t *testing.T) { b := board3() outsider := submitOne(t, b) uassert.Equal(t, 3, b.Eligible(outsider)) uassert.Equal(t, 2, b.Majority(outsider)) // A member applying for their own grant shrinks the eligible set, and // with it the bar: two of the remaining two must agree. insider, err := b.Submit(alice, "self", "", []*Milestone{NewMilestone("m", 1)}, 1) urequire.NoError(t, err) uassert.Equal(t, 2, b.Eligible(insider)) uassert.Equal(t, 2, b.Majority(insider)) } func TestApprovalNeedsAMajority(t *testing.T) { b := board3() r := submitOne(t, b) st, err := b.Vote(alice, r.ID, true, "good", 11) urequire.NoError(t, err) uassert.Equal(t, "pending", st.String(), "one of three is not a majority") st, err = b.Vote(bob, r.ID, true, "agreed", 12) urequire.NoError(t, err) uassert.Equal(t, "approved", st.String()) uassert.Equal(t, int64(12), r.DecidedAt) uassert.Equal(t, int64(500), r.Outstanding(), "approval commits the full ask") uassert.Equal(t, int64(500), b.Committed()) yes, no := r.Tally() uassert.Equal(t, 2, yes) uassert.Equal(t, 0, no) uassert.Equal(t, "good", r.BallotOf(alice).Reason) uassert.True(t, r.BallotOf(carol) == nil, "carol never voted") } func TestRejectionNeedsAMajorityToo(t *testing.T) { b := board3() r := submitOne(t, b) urequire.NoError(t, errOf(b.Vote(alice, r.ID, false, "out of scope", 11))) st, err := b.Vote(bob, r.ID, false, "same", 12) urequire.NoError(t, err) uassert.Equal(t, "rejected", st.String()) uassert.Equal(t, int64(0), b.Committed(), "a rejected grant commits nothing") } func TestVoteRefusals(t *testing.T) { b := board3() r := submitOne(t, b) self, err := b.Submit(alice, "self", "", []*Milestone{NewMilestone("m", 1)}, 1) urequire.NoError(t, err) _, err = b.Vote(alice, 999, true, "", 1) uassert.ErrorIs(t, err, ErrNoRequest) _, err = b.Vote(mallory, r.ID, true, "", 1) uassert.ErrorIs(t, err, ErrNotMember) _, err = b.Vote(alice, self.ID, true, "vote for myself", 1) uassert.ErrorIs(t, err, ErrConflict) long := "" for i := 0; i < MaxReason+1; i++ { long += "z" } _, err = b.Vote(alice, r.ID, true, long, 1) uassert.ErrorIs(t, err, ErrBadReason) urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "yes", 1))) _, err = b.Vote(alice, r.ID, false, "changed my mind", 2) uassert.ErrorIs(t, err, ErrAlreadyVoted, "a ballot is permanent") urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "yes", 3))) _, err = b.Vote(carol, r.ID, true, "too late", 4) uassert.ErrorIs(t, err, ErrNotPending, "decided requests take no more ballots") } func TestDecidesPreviewsWithoutCasting(t *testing.T) { b := board3() r := submitOne(t, b) settles, to := b.Decides(r.ID, alice, true) uassert.False(t, settles, "the first of three does not settle it") uassert.Equal(t, 0, len(r.Votes), "previewing casts nothing") urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 11))) settles, to = b.Decides(r.ID, bob, true) uassert.True(t, settles) uassert.Equal(t, "approved", to.String()) settles, _ = b.Decides(r.ID, bob, false) uassert.False(t, settles, "a no here only makes it one-all") settles, _ = b.Decides(r.ID, mallory, true) uassert.False(t, settles, "a non-member decides nothing") settles, _ = b.Decides(r.ID, alice, true) uassert.False(t, settles, "a member who already voted decides nothing") settles, _ = b.Decides(999, bob, true) uassert.False(t, settles, "an unknown request decides nothing") } func TestWithdraw(t *testing.T) { b := board3() r := submitOne(t, b) uassert.ErrorIs(t, b.Withdraw(mallory, r.ID, 5), ErrNotApplicant) uassert.ErrorIs(t, b.Withdraw(dave, 999, 5), ErrNoRequest) urequire.NoError(t, b.Withdraw(dave, r.ID, 5)) uassert.Equal(t, "withdrawn", r.Status.String()) uassert.ErrorIs(t, b.Withdraw(dave, r.ID, 6), ErrNotPending) } func TestProofRefusals(t *testing.T) { b := board3() r := submitOne(t, b) good := Proof{Kind: "url", Ref: "https://example.com/pr/1", Height: 20} uassert.ErrorIs(t, b.SubmitProof(dave, r.ID, 0, good), ErrNotApproved, "not approved yet") urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "", 12))) uassert.ErrorIs(t, b.SubmitProof(mallory, r.ID, 0, good), ErrNotApplicant) uassert.ErrorIs(t, b.SubmitProof(dave, r.ID, 7, good), ErrNoMilestone) uassert.ErrorIs(t, b.SubmitProof(dave, r.ID, 1, good), ErrOutOfOrder, "milestone 1 before 0") bad := []Proof{ {Kind: "screenshot", Ref: "x"}, {Kind: "url", Ref: ""}, {Kind: "text", Ref: "ok", Note: longString(MaxNote + 1)}, {Kind: "url", Ref: longString(MaxRef + 1)}, } for _, p := range bad { uassert.ErrorIs(t, b.SubmitProof(dave, r.ID, 0, p), ErrBadProof, p.Kind) } urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, good)) uassert.ErrorIs(t, b.SubmitProof(dave, r.ID, 0, good), ErrProofPending, "one at a time") } func TestReleaseRunsThroughToCompletion(t *testing.T) { b := board3() r := submitOne(t, b) urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "", 12))) // Milestone 0. urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "url", Ref: "https://x/1", Height: 20})) out, err := b.Review(alice, r.ID, 0, true, "looks done", 21) urequire.NoError(t, err) uassert.Equal(t, "under review", out.String(), "one reviewer is not a majority") uassert.False(t, r.Milestones[0].Released) out, err = b.Review(carol, r.ID, 0, true, "confirmed", 22) urequire.NoError(t, err) uassert.Equal(t, "accepted", out.String()) uassert.True(t, r.Milestones[0].Released) uassert.Equal(t, int64(22), r.Milestones[0].ReleasedAt) uassert.Equal(t, int64(100), r.Paid()) uassert.Equal(t, int64(400), r.Outstanding()) uassert.Equal(t, "approved", r.Status.String(), "one tranche left") uassert.Equal(t, 1, r.Next()) // Milestone 1 completes the grant. urequire.NoError(t, b.SubmitProof(dave, r.ID, 1, Proof{Kind: "hash", Ref: "deadbeef", Height: 30})) urequire.NoError(t, errOf2(b.Review(alice, r.ID, 1, true, "", 31))) out, err = b.Review(bob, r.ID, 1, true, "", 32) urequire.NoError(t, err) uassert.Equal(t, "accepted", out.String()) uassert.Equal(t, "completed", r.Status.String()) uassert.Equal(t, -1, r.Next()) uassert.Equal(t, int64(500), r.Paid()) uassert.Equal(t, int64(0), r.Outstanding(), "a completed grant commits nothing") uassert.Equal(t, int64(500), b.Disbursed()) uassert.Equal(t, int64(0), b.Committed()) } func TestRefusedProofKeepsTheGrantAliveAndOnTheRecord(t *testing.T) { b := board3() r := submitOne(t, b) urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "", 12))) urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "url", Ref: "https://x/draft", Height: 20})) urequire.NoError(t, errOf2(b.Review(alice, r.ID, 0, false, "this is a stub", 21))) out, err := b.Review(bob, r.ID, 0, false, "agreed, no tests", 22) urequire.NoError(t, err) uassert.Equal(t, "refused", out.String()) uassert.False(t, r.Milestones[0].Released) uassert.Equal(t, "approved", r.Status.String(), "a refused proof does not kill the grant") // The refused attempt stays readable, reasons and all. m := r.Milestones[0] urequire.Equal(t, 1, len(m.Attempts)) uassert.Equal(t, "this is a stub", m.Attempts[0].Reviews[0].Reason) uassert.True(t, m.Current() == nil, "the attempt is closed") // And a second attempt can carry. urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "url", Ref: "https://x/final", Height: 30})) urequire.NoError(t, errOf2(b.Review(alice, r.ID, 0, true, "now it is done", 31))) urequire.NoError(t, errOf2(b.Review(bob, r.ID, 0, true, "", 32))) uassert.True(t, r.Milestones[0].Released) uassert.Equal(t, 2, len(m.Attempts), "both attempts are kept") } func TestReviewRefusals(t *testing.T) { b := board3() r := submitOne(t, b) urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "", 12))) uassert.ErrorIs(t, errOf2(b.Review(alice, r.ID, 0, true, "", 20)), ErrNoProof) urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "text", Ref: "done", Height: 20})) uassert.ErrorIs(t, errOf2(b.Review(mallory, r.ID, 0, true, "", 21)), ErrNotMember) uassert.ErrorIs(t, errOf2(b.Review(alice, 999, 0, true, "", 21)), ErrNoRequest) uassert.ErrorIs(t, errOf2(b.Review(alice, r.ID, 9, true, "", 21)), ErrNoMilestone) urequire.NoError(t, errOf2(b.Review(alice, r.ID, 0, true, "", 21))) uassert.ErrorIs(t, errOf2(b.Review(alice, r.ID, 0, false, "", 22)), ErrAlreadyVoted) } func TestApplicantMayNotReviewTheirOwnProof(t *testing.T) { b := New(alice, bob, dave) // dave is a member AND the applicant here r, err := b.Submit(dave, "self funded", "", []*Milestone{NewMilestone("m", 10)}, 1) urequire.NoError(t, err) urequire.NoError(t, errOf(b.Vote(alice, r.ID, true, "", 2))) urequire.NoError(t, errOf(b.Vote(bob, r.ID, true, "", 3))) urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "text", Ref: "done", Height: 4})) uassert.ErrorIs(t, errOf2(b.Review(dave, r.ID, 0, true, "ship it", 5)), ErrConflict) } func TestMembershipRequestAddsAndRemoves(t *testing.T) { b := board3() add, err := b.SubmitMemberChange(alice, dave, true, "he reviews everything anyway", 5) urequire.NoError(t, err) uassert.Equal(t, "membership", add.Kind.String()) uassert.Equal(t, "Add "+dave.String(), add.Title) uassert.Equal(t, int64(0), add.Total(), "a membership request asks for no money") urequire.NoError(t, errOf(b.Vote(alice, add.ID, true, "", 6))) st, err := b.Vote(bob, add.ID, true, "", 7) urequire.NoError(t, err) uassert.Equal(t, "completed", st.String(), "membership executes on approval") uassert.True(t, b.IsMember(dave)) uassert.Equal(t, int64(7), b.JoinedAt(dave), "joined at the deciding height") // Now four members: a majority excluding the subject is still two. rm, err := b.SubmitMemberChange(alice, carol, false, "inactive", 8) urequire.NoError(t, err) uassert.Equal(t, 3, b.Eligible(rm)) uassert.ErrorIs(t, errOf(b.Vote(carol, rm.ID, false, "no", 9)), ErrConflict, "the subject may not vote") urequire.NoError(t, errOf(b.Vote(alice, rm.ID, true, "", 10))) urequire.NoError(t, errOf(b.Vote(bob, rm.ID, true, "", 11))) uassert.False(t, b.IsMember(carol)) uassert.Equal(t, 3, b.MemberCount()) } func TestMemberChangeRefusals(t *testing.T) { b := board3() _, err := b.SubmitMemberChange(mallory, dave, true, "", 1) uassert.ErrorIs(t, err, ErrNotMember, "only members touch the board's composition") _, err = b.SubmitMemberChange(alice, alice, true, "", 1) uassert.ErrorIs(t, err, ErrIsMember) _, err = b.SubmitMemberChange(alice, dave, false, "", 1) uassert.ErrorIs(t, err, ErrNotMember, "cannot remove a non-member") _, err = b.SubmitMemberChange(alice, address(""), true, "", 1) uassert.ErrorIs(t, err, ErrBadAddress) solo := New(alice) _, err = solo.SubmitMemberChange(alice, alice, false, "", 1) uassert.ErrorIs(t, err, ErrLastMember, "the board cannot empty itself") } // Standing is the whole reason a removed member's yes does not keep carrying // decisions they are no longer part of. func TestStandingDropsBallotsFromFormerMembers(t *testing.T) { b := board3() r := submitOne(t, b) urequire.NoError(t, errOf(b.Vote(carol, r.ID, true, "I like it", 11))) rm, err := b.SubmitMemberChange(alice, carol, false, "left the project", 12) urequire.NoError(t, err) urequire.NoError(t, errOf(b.Vote(alice, rm.ID, true, "", 13))) urequire.NoError(t, errOf(b.Vote(bob, rm.ID, true, "", 14))) urequire.Equal(t, 2, b.MemberCount()) yes, _ := r.Tally() uassert.Equal(t, 1, yes, "the ballot stays on the record") yes, _ = b.Standing(r) uassert.Equal(t, 0, yes, "but it no longer counts") // With two members and an outsider applicant the bar is two, so alice // alone still cannot carry it. uassert.Equal(t, 2, b.Majority(r)) st, err := b.Vote(alice, r.ID, true, "", 15) urequire.NoError(t, err) uassert.Equal(t, "pending", st.String()) } func TestListAndSizeStayInIDOrder(t *testing.T) { b := board3() for i := 0; i < 12; i++ { _, err := b.Submit(dave, "r", "", []*Milestone{NewMilestone("m", 1)}, int64(i)) urequire.NoError(t, err) } all := b.List() urequire.Equal(t, 12, len(all)) uassert.Equal(t, 12, b.Size()) for i, r := range all { uassert.Equal(t, i+1, r.ID, "ids come back in numeric order past nine") } } func longString(n int) string { s := "" for i := 0; i < n; i++ { s += "x" } return s } // errOf and errOf2 drop the leading return value of Vote and Review so a call // reads as a single assertion. func errOf(_ Status, err error) error { return err } func errOf2(_ Outcome, err error) error { return err } func TestFilingForSomeoneElseNeedsAReason(t *testing.T) { b := board3() ms := func() []*Milestone { return []*Milestone{NewMilestone("m", 100)} } _, err := b.SubmitFor(dave, mallory, "", "t", "", ms(), 10) uassert.ErrorIs(t, err, ErrNeedReason, "a request for someone else has to say why") // The same address is just a self request, and needs nothing extra. self, err := b.SubmitFor(dave, dave, "", "t", "", ms(), 10) urequire.NoError(t, err) uassert.False(t, self.OnBehalf()) _, err = b.SubmitFor(dave, address("not-an-address"), "because", "t", "", ms(), 10) uassert.ErrorIs(t, err, ErrBadAddress) long := "" for i := 0; i <= MaxReason; i++ { long += "x" } _, err = b.SubmitFor(dave, mallory, long, "t", "", ms(), 10) uassert.ErrorIs(t, err, ErrBadReason) r, err := b.SubmitFor(dave, mallory, "their account is empty", "t", "", ms(), 10) urequire.NoError(t, err) uassert.True(t, r.OnBehalf()) uassert.Equal(t, mallory.String(), r.Payee().String()) uassert.Equal(t, dave.String(), r.Applicant.String()) } // A member who is paid by a request someone else filed is the same conflict of // interest as a member who filed it, with one address in between. func TestBothPartiesAreBarredFromVotingOnAGrant(t *testing.T) { b := board3() r, err := b.SubmitFor(alice, bob, "bob asked me to file it", "t", "", []*Milestone{NewMilestone("m", 100)}, 10) urequire.NoError(t, err) uassert.True(t, r.Excludes(alice), "the applicant") uassert.True(t, r.Excludes(bob), "the beneficiary") uassert.False(t, r.Excludes(carol)) uassert.Equal(t, 1, b.Eligible(r), "three members, two of them party to it") uassert.Equal(t, 1, b.Majority(r)) _, err = b.Vote(alice, r.ID, true, "", 11) uassert.ErrorIs(t, err, ErrConflict, "the applicant may not vote") _, err = b.Vote(bob, r.ID, true, "", 11) uassert.ErrorIs(t, err, ErrConflict, "the payee may not vote") st, err := b.Vote(carol, r.ID, true, "fine", 12) urequire.NoError(t, err) uassert.Equal(t, "approved", st.String(), "the one eligible member carries it") // And the same exclusion holds on the proof review, which is where the // money actually moves. urequire.NoError(t, b.SubmitProof(alice, r.ID, 0, Proof{Kind: "text", Ref: "done", Height: 13})) _, err = b.Review(bob, r.ID, 0, true, "", 14) uassert.ErrorIs(t, err, ErrConflict, "the payee may not review their own proof") } // The payee of a request filed for an empty account cannot transact until the // first tranche lands, so the filer has to be able to prove the work; once the // payee can transact, they have to be able to prove their own. func TestEitherPartyMaySubmitAProof(t *testing.T) { b := board3() r, err := b.SubmitFor(dave, mallory, "empty account", "t", "", []*Milestone{NewMilestone("one", 100), NewMilestone("two", 100)}, 10) urequire.NoError(t, err) b.Vote(alice, r.ID, true, "", 11) b.Vote(bob, r.ID, true, "", 12) urequire.Equal(t, "approved", r.Status.String()) uassert.ErrorIs(t, b.SubmitProof(carol, r.ID, 0, Proof{Kind: "text", Ref: "x", Height: 13}), ErrNotApplicant, "a third party may not") urequire.NoError(t, b.SubmitProof(dave, r.ID, 0, Proof{Kind: "text", Ref: "filer", Height: 13})) b.Review(alice, r.ID, 0, true, "", 14) b.Review(bob, r.ID, 0, true, "", 15) urequire.True(t, r.Milestones[0].Released) urequire.NoError(t, b.SubmitProof(mallory, r.ID, 1, Proof{Kind: "text", Ref: "payee", Height: 16})) }
  10. #10lifecycle.gno
  11. #11package grants // This file holds every state transition of the board. Each one takes the // acting address and the block height from the caller rather than reading // them from the chain, which is what keeps the package pure and the whole // lifecycle exercisable in a plain unit test. // NewMilestone is a small constructor so callers do not build the struct (and // its Attempts slice) by hand. func NewMilestone(title string, amount int64) *Milestone { return &Milestone{Title: title, Amount: amount} } // Submit files a grant application for the applicant themselves. Anyone may // apply; being a member is not required and does not help, since a member is // barred from voting on their own request. func (b *Board) Submit(applicant address, title, body string, ms []*Milestone, height int64) (*Request, error) { return b.SubmitFor(applicant, applicant, "", title, body, ms, height) } // SubmitFor files a grant application whose money goes to beneficiary rather // than to the applicant, with a reason for the detour. // // The reason is required, and it is required because it is the only thing on // the page that answers the question a reader will have: why is this person // asking for someone else's money. The usual honest answer is that the payee // has an empty account and cannot pay the gas to ask, which is exactly the // case a grant board exists to serve and exactly the case an impersonation // looks like. Making the claim explicit is what lets a member check it. // // Passing the applicant's own address is the same as Submit and needs no // reason. func (b *Board) SubmitFor(applicant, beneficiary address, reason, title, body string, ms []*Milestone, height int64) (*Request, error) { if !applicant.IsValid() || !beneficiary.IsValid() { return nil, ErrBadAddress } if len(reason) > MaxReason { return nil, ErrBadReason } if beneficiary != applicant && reason == "" { return nil, ErrNeedReason } if err := checkTitle(title); err != nil { return nil, err } if len(body) > MaxBody { return nil, ErrBadBody } if len(ms) == 0 || len(ms) > MaxMilestones { return nil, ErrBadMilestones } for _, m := range ms { if m == nil || m.Amount <= 0 { return nil, ErrBadAmount } if err := checkTitle(m.Title); err != nil { return nil, err } } return b.file(&Request{ Kind: KindGrant, Applicant: applicant, Beneficiary: beneficiary, Reason: reason, Title: title, Body: body, Milestones: ms, CreatedAt: height, }), nil } // SubmitMemberChange files a request to add or remove a board member. Only a // member may file one: opening the board's own composition to anyone with a // keypair is how a grant board gets captured. func (b *Board) SubmitMemberChange(proposer, subject address, add bool, body string, height int64) (*Request, error) { if !b.IsMember(proposer) { return nil, ErrNotMember } if !subject.IsValid() { return nil, ErrBadAddress } if len(body) > MaxBody { return nil, ErrBadBody } switch { case add && b.IsMember(subject): return nil, ErrIsMember case !add && !b.IsMember(subject): return nil, ErrNotMember case !add && b.MemberCount() == 1: return nil, ErrLastMember } title := "Remove " + subject.String() if add { title = "Add " + subject.String() } return b.file(&Request{ Kind: KindMember, Applicant: proposer, Beneficiary: proposer, Title: title, Body: body, Subject: subject, Add: add, CreatedAt: height, }), nil } func (b *Board) file(r *Request) *Request { r.ID = b.nextID b.nextID++ b.requests.Set(idKey(r.ID), r) return r } // Withdraw lets an applicant pull their own request before it is decided. func (b *Board) Withdraw(caller address, id int, height int64) error { r := b.Get(id) if r == nil { return ErrNoRequest } if r.Applicant != caller { return ErrNotApplicant } if r.Status != Pending { return ErrNotPending } r.Status, r.DecidedAt = Withdrawn, height return nil } // Standing counts only the ballots of addresses that are members right now. // Decisions use this, not Request.Tally: a ballot is a permanent record of // what someone said, but it stops carrying weight the moment they leave the // board. The two numbers differ exactly when a voter has since been removed, // and a good renderer shows both. func (b *Board) Standing(r *Request) (yes, no int) { return b.standing(r.Votes) } // Decides previews what a ballot would do without casting it, so a caller can // check a precondition it cannot roll back: a realm, for instance, refusing // to let a grant carry that its treasury cannot cover. It reports false for a // ballot that would be refused anyway. func (b *Board) Decides(id int, voter address, approve bool) (bool, Status) { r := b.Get(id) if r == nil || r.Status != Pending { return false, Pending } if !b.IsMember(voter) || r.Excludes(voter) || r.BallotOf(voter) != nil { return false, Pending } yes, no := b.Standing(r) if approve { yes++ } else { no++ } switch m := b.Majority(r); { case yes >= m: return true, Approved case no >= m: return true, Rejected } return false, Pending } // ReviewDecides is Decides for a proof review: it reports whether this // verdict would close the attempt under review, and how. A realm uses it to // check, before recording anything, that it can actually pay a tranche it is // about to release. func (b *Board) ReviewDecides(id, idx int, voter address, accept bool) (bool, Outcome) { r := b.Get(id) if r == nil || r.Kind != KindGrant || r.Status != Approved { return false, UnderReview } if idx < 0 || idx >= len(r.Milestones) { return false, UnderReview } a := r.Milestones[idx].Current() if a == nil { return false, UnderReview } if !b.IsMember(voter) || r.Excludes(voter) || find(a.Reviews, voter) != nil { return false, UnderReview } yes, no := b.standing(a.Reviews) if accept { yes++ } else { no++ } switch m := b.Majority(r); { case yes >= m: return true, Accepted case no >= m: return true, Refused } return false, UnderReview } // standing counts ballots from current members only. See Standing. func (b *Board) standing(bs []Ballot) (yes, no int) { for _, v := range bs { if !b.IsMember(v.Voter) { continue } if v.Approve { yes++ } else { no++ } } return yes, no } // Vote casts one member's ballot on a request and returns the status it left // the request in. A member votes once; there is no changing your mind, which // is the price of every ballot being a permanent public statement. func (b *Board) Vote(voter address, id int, approve bool, reason string, height int64) (Status, error) { r := b.Get(id) if r == nil { return Pending, ErrNoRequest } if !b.IsMember(voter) { return r.Status, ErrNotMember } if r.Status != Pending { return r.Status, ErrNotPending } if r.Excludes(voter) { return r.Status, ErrConflict } if r.BallotOf(voter) != nil { return r.Status, ErrAlreadyVoted } if len(reason) > MaxReason { return r.Status, ErrBadReason } r.Votes = append(r.Votes, Ballot{Voter: voter, Approve: approve, Reason: reason, Height: height}) yes, no := b.Standing(r) switch m := b.Majority(r); { case yes >= m: r.Status, r.DecidedAt = Approved, height if r.Kind == KindMember { b.applyMemberChange(r, height) } case no >= m: r.Status, r.DecidedAt = Rejected, height } return r.Status, nil } // applyMemberChange is the whole execution engine for KindMember: a carried // membership request takes effect at once, with nothing to claim afterwards, // so it goes straight from Approved to Completed. func (b *Board) applyMemberChange(r *Request, height int64) { if r.Add { b.members.Set(r.Subject.String(), height) } else { b.members.Remove(r.Subject.String()) } r.Status = Completed } // SubmitProof offers evidence for the next unreleased milestone of an // approved grant. Milestones are earned in order, and only one proof is under // review at a time. // // Either the applicant or the beneficiary may submit. On a request filed for // an empty account, the payee cannot transact until the first tranche lands, // so restricting this to the payee would strand the grant it was filed to // unstick; restricting it to the filer would leave the payee unable to show // their own work once they can. func (b *Board) SubmitProof(caller address, id, idx int, p Proof) error { r := b.Get(id) if r == nil { return ErrNoRequest } if r.Kind != KindGrant || r.Status != Approved { return ErrNotApproved } if r.Applicant != caller && r.Payee() != caller { return ErrNotApplicant } if idx < 0 || idx >= len(r.Milestones) { return ErrNoMilestone } if idx != r.Next() { return ErrOutOfOrder } if err := checkProof(p); err != nil { return err } m := r.Milestones[idx] if m.Current() != nil { return ErrProofPending } m.Attempts = append(m.Attempts, &Attempt{Proof: p, Outcome: UnderReview}) return nil } // Review casts a member's verdict on the proof currently under review and // returns the outcome that verdict left the attempt in. Accepting it releases // the tranche, which in this package means marking it Released and nothing // more; moving the coins is the caller's job, and Request.Paid tells it how // much it now owes. Refusing closes the attempt and lets the applicant try // again with better evidence. func (b *Board) Review(voter address, id, idx int, accept bool, reason string, height int64) (Outcome, error) { r := b.Get(id) if r == nil { return UnderReview, ErrNoRequest } if !b.IsMember(voter) { return UnderReview, ErrNotMember } if r.Kind != KindGrant || r.Status != Approved { return UnderReview, ErrNotApproved } if idx < 0 || idx >= len(r.Milestones) { return UnderReview, ErrNoMilestone } if r.Excludes(voter) { return UnderReview, ErrConflict } if len(reason) > MaxReason { return UnderReview, ErrBadReason } m := r.Milestones[idx] a := m.Current() if a == nil { return UnderReview, ErrNoProof } if find(a.Reviews, voter) != nil { return UnderReview, ErrAlreadyVoted } a.Reviews = append(a.Reviews, Ballot{Voter: voter, Approve: accept, Reason: reason, Height: height}) yes, no := b.standing(a.Reviews) switch mj := b.Majority(r); { case yes >= mj: a.Outcome, a.ClosedAt = Accepted, height m.Released, m.ReleasedAt = true, height if r.Next() == -1 { r.Status = Completed } case no >= mj: a.Outcome, a.ClosedAt = Refused, height } return a.Outcome, nil } func checkTitle(s string) error { if len(s) == 0 || len(s) > MaxTitle { return ErrBadTitle } return nil } func checkProof(p Proof) error { switch p.Kind { case "url", "hash", "text": default: return ErrBadProof } if len(p.Ref) == 0 || len(p.Ref) > MaxRef || len(p.Note) > MaxNote { return ErrBadProof } return nil }
  12. #12program.gno
  13. #13package grants import ( "strconv" "strings" ) // Program is a Board plus the money that moves around it: what came in, what // went out, and the denomination it is all counted in. // // It still holds no coins and calls no banker. A realm hands it the current // treasury balance whenever a decision depends on one, and executes the // Payment it hands back. That split is the whole point: everything a grant // program decides is testable without a node, and the realm on top is thin // enough to read in one screen. type Program struct { Board *Board Denom string Donations []Donation Payments []Payment } // Donation is one top-up of the treasury, credited to a name. type Donation struct { Height int64 From address Amount int64 } // Payment is one released tranche. The ledger of these is a program's answer // to "where did the money go". type Payment struct { Height int64 Request int Milestone int To address Amount int64 } // NewProgram returns a program whose board is seated with founders. denom is // the coin everything is counted in ("ugnot" on gno.land); it is carried so a // renderer can label amounts without the realm telling it twice. func NewProgram(denom string, founders ...address) *Program { return &Program{Board: New(founders...), Denom: denom} } // Fund records a top-up. The coins have already moved by the time this is // called: a realm calls it to put a name next to a transfer that would // otherwise be anonymous. func (p *Program) Fund(from address, amount, height int64) error { if amount <= 0 { return ErrNothingSent } if !from.IsValid() { return ErrBadAddress } p.Donations = append(p.Donations, Donation{Height: height, From: from, Amount: amount}) return nil } // Apply files a grant request from a milestone spec, "design:100,ship:400". // See ParseMilestones for the grammar. func (p *Program) Apply(applicant address, title, body, spec string, height int64) (*Request, error) { return p.ApplyFor(applicant, applicant, "", title, body, spec, height) } // ApplyFor is Apply for a request whose money goes to someone other than the // address filing it. See Board.SubmitFor for why the reason is required. func (p *Program) ApplyFor(applicant, beneficiary address, reason, title, body, spec string, height int64) (*Request, error) { ms, err := ParseMilestones(spec) if err != nil { return nil, err } return p.Board.SubmitFor(applicant, beneficiary, reason, title, body, ms, height) } // Review casts a member's verdict on the proof under review and, when that // verdict releases the tranche, records the payment and hands it back for the // caller to execute. // // balance is what the treasury holds right now. A verdict that would release // more than that is refused with ErrUnderfunded and changes NOTHING: no // ballot, no release, no payment. That ordering is the reason Board.Review is // not called directly by a realm. A refusal is always free to record, since it // costs the treasury nothing. func (p *Program) Review(voter address, id, idx int, accept bool, reason string, height, balance int64) (Outcome, *Payment, error) { if releases, out := p.Board.ReviewDecides(id, idx, voter, accept); releases && out == Accepted { if amount := p.Board.Get(id).Milestones[idx].Amount; balance < amount { return UnderReview, nil, ErrUnderfunded } } out, err := p.Board.Review(voter, id, idx, accept, reason, height) if err != nil || out != Accepted { return out, nil, err } r := p.Board.Get(id) pay := Payment{ Height: height, Request: id, Milestone: idx, To: r.Payee(), Amount: r.Milestones[idx].Amount, } p.Payments = append(p.Payments, pay) return out, &pay, nil } // Raised is everything ever donated through Fund. func (p *Program) Raised() int64 { var n int64 for _, d := range p.Donations { n += d.Amount } return n } // Disbursed is everything ever released. func (p *Program) Disbursed() int64 { return p.Board.Disbursed() } // Committed is what approved grants can still claim. func (p *Program) Committed() int64 { return p.Board.Committed() } // Available is balance minus what is already promised. It goes NEGATIVE when // the board has approved more than the treasury holds, which is allowed on // purpose: a board that can only approve what it already has cannot approve // anything before a donor shows up. Renderers say so out loud. func (p *Program) Available(balance int64) int64 { return balance - p.Committed() } // ParseMilestones reads "design:100,ship:400" into milestones, amounts in the // program's denomination, paid in the order written. A title may not contain a // comma, and the amount is whatever follows the LAST colon, so // "port gno:land tooling:250" parses the way it reads. func ParseMilestones(spec string) ([]*Milestone, error) { out := []*Milestone{} for _, part := range strings.Split(spec, ",") { part = strings.TrimSpace(part) if part == "" { continue } i := strings.LastIndex(part, ":") if i < 0 { return nil, ErrBadSpec } amount, err := strconv.ParseInt(strings.TrimSpace(part[i+1:]), 10, 64) if err != nil { return nil, ErrBadSpec } out = append(out, NewMilestone(strings.TrimSpace(part[:i]), amount)) } if len(out) == 0 { return nil, ErrBadSpec } return out, nil }
  14. #14program_test.gno
  15. #15package grants import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) // prog is the fixture: three members, and dave applying from outside, so a // majority of the eligible set is two. func prog() *Program { return NewProgram("ugnot", alice, bob, carol) } func TestParseMilestones(t *testing.T) { cases := []struct { name string spec string titles []string amounts []int64 }{ {"one", "ship:400", []string{"ship"}, []int64{400}}, {"two", "design:100,ship:400", []string{"design", "ship"}, []int64{100, 400}}, {"spaces", " design : 100 , ship : 400 ", []string{"design", "ship"}, []int64{100, 400}}, {"trailing comma", "ship:400,", []string{"ship"}, []int64{400}}, {"colon in the title", "port gno:land tooling:250", []string{"port gno:land tooling"}, []int64{250}}, } for _, tc := range cases { ms, err := ParseMilestones(tc.spec) if !uassert.NoError(t, err, tc.name) { continue } urequire.Equal(t, len(tc.titles), len(ms), tc.name) for i := range ms { uassert.Equal(t, tc.titles[i], ms[i].Title, tc.name) uassert.Equal(t, tc.amounts[i], ms[i].Amount, tc.name) } } for _, bad := range []string{"", " ", ",,", "ship", "ship:", "ship:many", "ship:1:2:x"} { _, err := ParseMilestones(bad) uassert.ErrorIs(t, err, ErrBadSpec, "should be rejected: "+bad) } } func TestFundRecordsTheDonor(t *testing.T) { p := prog() uassert.ErrorIs(t, p.Fund(dave, 0, 10), ErrNothingSent) uassert.ErrorIs(t, p.Fund(dave, -5, 10), ErrNothingSent) uassert.ErrorIs(t, p.Fund(address(""), 5, 10), ErrBadAddress) uassert.Equal(t, 0, len(p.Donations), "none of those were recorded") urequire.NoError(t, p.Fund(dave, 500, 10)) urequire.NoError(t, p.Fund(mallory, 250, 11)) urequire.Equal(t, 2, len(p.Donations)) uassert.Equal(t, dave.String(), p.Donations[0].From.String()) uassert.Equal(t, int64(500), p.Donations[0].Amount) uassert.Equal(t, int64(10), p.Donations[0].Height) uassert.Equal(t, int64(750), p.Raised()) } func TestApplyGoesThroughTheSpec(t *testing.T) { p := prog() r, err := p.Apply(dave, "Ship it", "two tranches", "alpha:100,beta:400", 10) urequire.NoError(t, err) uassert.Equal(t, int64(500), r.Total()) uassert.Equal(t, 2, len(r.Milestones)) uassert.Equal(t, "beta", r.Milestones[1].Title) _, err = p.Apply(dave, "Bad", "", "no amount here", 10) uassert.ErrorIs(t, err, ErrBadSpec) uassert.Equal(t, 1, p.Board.Size(), "the bad one was never filed") } // Available is allowed to go negative. That is the whole reason Review takes // a balance instead of trusting the board's own arithmetic. func TestAvailableGoesNegativeWhenTheBoardOverPromises(t *testing.T) { p := prog() r, err := p.Apply(dave, "Ship it", "", "alpha:100,beta:400", 10) urequire.NoError(t, err) uassert.Equal(t, int64(0), p.Committed(), "pending promises nothing") uassert.Equal(t, int64(0), p.Available(0)) urequire.NoError(t, errOf(p.Board.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(p.Board.Vote(bob, r.ID, true, "", 12))) uassert.Equal(t, int64(500), p.Committed()) uassert.Equal(t, int64(-500), p.Available(0), "approved with an empty treasury") uassert.Equal(t, int64(100), p.Available(600)) } func TestReviewRefusesTheVerdictItCannotPay(t *testing.T) { p := prog() r, err := p.Apply(dave, "Ship it", "", "alpha:100,beta:400", 10) urequire.NoError(t, err) urequire.NoError(t, errOf(p.Board.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(p.Board.Vote(bob, r.ID, true, "", 12))) urequire.NoError(t, p.Board.SubmitProof(dave, r.ID, 0, Proof{Kind: "url", Ref: "https://x/1", Height: 20})) // Not the deciding ballot, so the balance is irrelevant: it records. out, pay, err := p.Review(alice, r.ID, 0, true, "looks done", 21, 0) urequire.NoError(t, err) uassert.Equal(t, "under review", out.String()) uassert.True(t, pay == nil, "nothing to pay yet") // The deciding ballot against an empty treasury changes nothing at all. out, pay, err = p.Review(bob, r.ID, 0, true, "agreed", 22, 99) uassert.ErrorIs(t, err, ErrUnderfunded) uassert.True(t, pay == nil) uassert.Equal(t, 1, len(r.Milestones[0].Current().Reviews), "the ballot was not recorded") uassert.False(t, r.Milestones[0].Released) uassert.Equal(t, 0, len(p.Payments)) // A refusal from the same member costs the treasury nothing, so it lands, // even against a balance of zero. It does not settle anything on its own: // alice already accepted, so this is one all against a bar of two. out, pay, err = p.Review(bob, r.ID, 0, false, "not convinced", 23, 0) urequire.NoError(t, err) uassert.Equal(t, "under review", out.String()) uassert.True(t, pay == nil) uassert.Equal(t, 2, len(r.Milestones[0].Current().Reviews), "this one WAS recorded") // The second refusal reaches the bar and closes the attempt, still with // an empty treasury, because refusing costs nothing. out, pay, err = p.Review(carol, r.ID, 0, false, "same", 24, 0) urequire.NoError(t, err) uassert.Equal(t, "refused", out.String()) uassert.True(t, pay == nil) uassert.False(t, r.Milestones[0].Released) uassert.Equal(t, "approved", r.Status.String(), "a refused proof does not kill the grant") } func TestReviewHandsBackThePaymentToExecute(t *testing.T) { p := prog() r, err := p.Apply(dave, "Ship it", "", "alpha:100,beta:400", 10) urequire.NoError(t, err) urequire.NoError(t, errOf(p.Board.Vote(alice, r.ID, true, "", 11))) urequire.NoError(t, errOf(p.Board.Vote(bob, r.ID, true, "", 12))) urequire.NoError(t, p.Board.SubmitProof(dave, r.ID, 0, Proof{Kind: "url", Ref: "https://x/1", Height: 20})) urequire.NoError(t, errOf3(p.Review(alice, r.ID, 0, true, "", 21, 500))) out, pay, err := p.Review(carol, r.ID, 0, true, "", 22, 500) urequire.NoError(t, err) uassert.Equal(t, "accepted", out.String()) urequire.True(t, pay != nil, "the caller is handed a payment to execute") uassert.Equal(t, dave.String(), pay.To.String()) uassert.Equal(t, int64(100), pay.Amount) uassert.Equal(t, r.ID, pay.Request) uassert.Equal(t, 0, pay.Milestone) uassert.Equal(t, int64(22), pay.Height) urequire.Equal(t, 1, len(p.Payments), "and it is on the ledger") uassert.Equal(t, int64(100), p.Disbursed()) uassert.Equal(t, int64(400), p.Committed()) uassert.Equal(t, int64(300), p.Available(700)) // Second tranche completes it. urequire.NoError(t, p.Board.SubmitProof(dave, r.ID, 1, Proof{Kind: "hash", Ref: "deadbeef", Height: 30})) urequire.NoError(t, errOf3(p.Review(alice, r.ID, 1, true, "", 31, 400))) out, pay, err = p.Review(bob, r.ID, 1, true, "", 32, 400) urequire.NoError(t, err) uassert.Equal(t, "accepted", out.String()) urequire.True(t, pay != nil) uassert.Equal(t, int64(400), pay.Amount) uassert.Equal(t, "completed", r.Status.String()) uassert.Equal(t, int64(500), p.Disbursed()) uassert.Equal(t, int64(0), p.Committed()) uassert.Equal(t, 2, len(p.Payments)) } // A one-member program is a legitimate configuration (it is what a personal // board is), and its majority is one. It still cannot self-grant. func TestOneMemberBoard(t *testing.T) { p := NewProgram("ugnot", alice) r, err := p.Apply(dave, "Ship it", "", "alpha:100", 10) urequire.NoError(t, err) uassert.Equal(t, 1, p.Board.Eligible(r)) uassert.Equal(t, 1, p.Board.Majority(r)) st, err := p.Board.Vote(alice, r.ID, true, "sure", 11) urequire.NoError(t, err) uassert.Equal(t, "approved", st.String(), "one signature carries a one-member board") own, err := p.Apply(alice, "Pay myself", "", "alpha:100", 12) urequire.NoError(t, err) uassert.Equal(t, 0, p.Board.Eligible(own), "the applicant is the whole board") uassert.Equal(t, 1, p.Board.Majority(own), "so the bar is unreachable, not zero") uassert.ErrorIs(t, errOf(p.Board.Vote(alice, own.ID, true, "", 13)), ErrConflict) uassert.Equal(t, "pending", own.Status.String(), "a sole member cannot self-grant") } func errOf3(_ Outcome, _ *Payment, err error) error { return err } func TestTheTrancheGoesToTheBeneficiary(t *testing.T) { p := NewProgram("ugnot", alice, bob) r, err := p.ApplyFor(dave, mallory, "mallory cannot pay the gas to ask", "t", "", "one:700", 10) urequire.NoError(t, err) p.Board.Vote(alice, r.ID, true, "", 11) p.Board.Vote(bob, r.ID, true, "", 12) urequire.NoError(t, p.Board.SubmitProof(dave, r.ID, 0, Proof{Kind: "text", Ref: "done", Height: 13})) _, pay, err := p.Review(alice, r.ID, 0, true, "", 14, 700) urequire.NoError(t, err) uassert.True(t, pay == nil, "neither party sits on the board, so both members must accept") _, pay, err = p.Review(bob, r.ID, 0, true, "", 15, 700) urequire.NoError(t, err) urequire.True(t, pay != nil, "the second accept releases it") uassert.Equal(t, mallory.String(), pay.To.String(), "the money goes to the payee, not the filer") uassert.Equal(t, int64(700), pay.Amount) uassert.Equal(t, mallory.String(), p.Payments[0].To.String(), "and so does the ledger row") }
  16. #16render.gno
  17. #17package grants import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // Renderer turns a Program into the three markdown pages a grant program // needs: the board, one request, and the ledger. Everything that differs // between one program and the next is a field, so a realm's whole Render is // building this literal and calling it. // // Build it INSIDE Render rather than storing it in a realm global: Note is a // func, and Balance has to be read fresh on every call anyway. // // # Escaping // // Everything a caller typed (titles, bodies, reasons, proofs, notes) is // escaped here, once, through p/moul/kit/ui and p/nt/markdown/sanitize. A // realm does not repeat it and must not pre-escape: escaping twice shows the // backslashes. The realm's OWN fields below (Title, Intro, Notes, Footer) are // chrome, are written by whoever deployed the realm, and are emitted as-is so // they can carry markdown. type Renderer struct { Program *Program Title string // page heading Intro string // markdown under the heading, already wrapped Notes []string // extra paragraphs on the board page: house rules, scope Path string // package path, for the gnokey examples Link string // gnoweb route prefix, e.g. "/r/moul/grant/v0" Treasury address // where the money sits Balance int64 // what it holds right now Footer string // closing paragraph on the board page // Note returns an extra callout for one request page, or "". Use it for // anything the library cannot know, such as a seeded request whose // applicant address has no key behind it. Note func(*Request) string } // Render serves the board at "", one request at "request/<id>", and the money // trail at "ledger". func (rr Renderer) Render(path string) string { path = strings.TrimSpace(path) switch { case path == "" || path == "/": return rr.index() case path == "ledger": return rr.ledger() case strings.HasPrefix(path, "request/"): return rr.request(strings.TrimPrefix(path, "request/")) } return "# " + rr.Title + "\n\nNo page `" + path + "`. Try [the board](" + rr.Link + ") or [the ledger](" + rr.Link + ":ledger).\n" } func (rr Renderer) index() string { p := rr.Program var b strings.Builder b.WriteString("# " + rr.Title + "\n\n") if rr.Intro != "" { b.WriteString(rr.Intro + "\n\n") } for _, n := range rr.Notes { b.WriteString(n + "\n\n") } b.WriteString("## Treasury\n\n") b.WriteString("| Field | Value |\n|---|---|\n") b.WriteString("| Address | " + ui.AddrFull(rr.Treasury) + " |\n") b.WriteString("| Balance | " + rr.amount(rr.Balance) + " |\n") b.WriteString("| Promised | " + rr.amount(p.Committed()) + " |\n") b.WriteString("| Unpromised | " + rr.amount(p.Available(rr.Balance)) + " |\n") b.WriteString("| Donated | " + rr.amount(p.Raised()) + " |\n") b.WriteString("| Paid out | " + rr.amount(p.Disbursed()) + " |\n\n") if short := -p.Available(rr.Balance); short > 0 { b.WriteString("The board has promised " + rr.amount(short) + " more than it holds. Approving a\n") b.WriteString("grant does not escrow anything, so a tranche can come due against an empty\n") b.WriteString("treasury; a review that would release one is refused until the money is there.\n") b.WriteString("Top it up with `Fund`, or by sending coins straight to the address above.\n\n") } b.WriteString("## Board\n\n") b.WriteString("A decision needs a majority of the members eligible to cast a ballot on it,\n") b.WriteString("recomputed every time: the party a decision is about never votes on it, so an\n") b.WriteString("applicant who sits on the board shrinks the room rather than packing it.\n\n") b.WriteString("| Member | Joined at height |\n|---|---|\n") for _, m := range p.Board.Members() { b.WriteString("| " + ui.AddrFull(m) + " | " + i64(p.Board.JoinedAt(m)) + " |\n") } b.WriteString("\n") b.WriteString("## Requests\n\n") all := p.Board.List() if len(all) == 0 { b.WriteString("Nothing has been asked of this board yet. `Apply` is open to anyone.\n\n") } else { b.WriteString("| # | Kind | Title | For | Asking | Status | Ballots |\n|---|---|---|---|---|---|---|\n") for _, r := range all { yes, no := p.Board.Standing(r) b.WriteString("| [" + strconv.Itoa(r.ID) + "](" + rr.Link + ":request/" + strconv.Itoa(r.ID) + ")" + " | " + r.Kind.String() + " | " + ui.Cell(r.Title) + " | " + rr.who(r) + " | " + rr.ask(r) + " | " + r.Status.String() + " | " + strconv.Itoa(yes) + " for, " + strconv.Itoa(no) + " against" + " |\n") } b.WriteString("\n") } b.WriteString("## Where the money went\n\n") b.WriteString("[The ledger](" + rr.Link + ":ledger) lists every donation in and every tranche\n") b.WriteString("out, with the height, the payee and the milestone it paid for.\n\n") b.WriteString("## Calling it\n\n") b.WriteString("```sh\n") b.WriteString("# put money in\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Fund -send 5000000" + p.Denom + " ...\n") b.WriteString("# ask for some\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Apply \\\n") b.WriteString(" -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...\n") b.WriteString("# ask on behalf of someone who cannot pay the gas to ask\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func ApplyFor \\\n") b.WriteString(" -args g1... -args 'their account is empty' \\\n") b.WriteString(" -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...\n") b.WriteString("# decide (members only)\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Vote -args 1 -args true -args 'reason' ...\n") b.WriteString("# show your work, then get paid for it\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func SubmitProof \\\n") b.WriteString(" -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ...\n") b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Review -args 1 -args 0 -args true -args 'looks done' ...\n") b.WriteString("```\n") if rr.Footer != "" { b.WriteString("\n" + rr.Footer + "\n") } return b.String() } func (rr Renderer) request(raw string) string { id, err := strconv.Atoi(raw) if err != nil { return "# " + rr.Title + "\n\n`" + raw + "` is not a request number.\n" } r := rr.Program.Board.Get(id) if r == nil { return "# " + rr.Title + "\n\nThere is no request " + raw + ".\n" } board := rr.Program.Board var b strings.Builder b.WriteString("# Request " + strconv.Itoa(r.ID) + ": " + ui.Inline(r.Title) + "\n\n") b.WriteString("| Field | Value |\n|---|---|\n") b.WriteString("| Kind | " + r.Kind.String() + " |\n") b.WriteString("| Applicant | " + ui.AddrFull(r.Applicant) + " |\n") if r.OnBehalf() { b.WriteString("| Beneficiary | " + ui.AddrFull(r.Beneficiary) + " |\n") } if r.Kind == KindMember { b.WriteString("| Subject | " + ui.AddrFull(r.Subject) + " |\n") b.WriteString("| Effect | " + addRemove(r.Add) + " |\n") } b.WriteString("| Status | " + r.Status.String() + " |\n") b.WriteString("| Filed at height | " + i64(r.CreatedAt) + " |\n") if r.DecidedAt != 0 { b.WriteString("| Decided at height | " + i64(r.DecidedAt) + " |\n") } if r.Kind == KindGrant { b.WriteString("| Asking | " + rr.amount(r.Total()) + " |\n") b.WriteString("| Paid so far | " + rr.amount(r.Paid()) + " |\n") b.WriteString("| Still owed | " + rr.amount(r.Outstanding()) + " |\n") } b.WriteString("\n") if r.OnBehalf() { b.WriteString("## Filed for someone else\n\n") b.WriteString("The applicant is not the payee: every tranche of this request pays\n") b.WriteString(ui.AddrFull(r.Beneficiary) + ". Both addresses are barred from voting on it, and\n") b.WriteString("either may submit a proof. The reason given:\n\n") b.WriteString("> " + ui.Inline(r.Reason) + "\n\n") b.WriteString("Nothing checks that reason. It is a claim by the applicant, and the point of\n") b.WriteString("printing it here is that a member can check it before voting.\n\n") } if rr.Note != nil { if note := rr.Note(r); note != "" { b.WriteString(note + "\n\n") } } if r.Body != "" { b.WriteString("## The ask\n\n" + block(r.Body) + "\n\n") } b.WriteString("## Ballots\n\n") yes, no := board.Standing(r) recorded, against := r.Tally() b.WriteString("Needs " + strconv.Itoa(board.Majority(r)) + " of " + strconv.Itoa(board.Eligible(r)) + " eligible members. Standing: " + strconv.Itoa(yes) + " for, " + strconv.Itoa(no) + " against.\n") if recorded != yes || against != no { b.WriteString("On the record: " + strconv.Itoa(recorded) + " for, " + strconv.Itoa(against) + " against. The difference is ballots cast by people who have since left the board;\n") b.WriteString("they stay on the record and stop counting.\n") } b.WriteString("\n") if len(r.Votes) == 0 { b.WriteString("Nobody has voted yet.\n\n") } else { b.WriteString(rr.ballots(r.Votes, "for", "against")) } if r.Kind != KindGrant { return b.String() } b.WriteString("## Milestones\n\n") b.WriteString("Earned in order. The applicant submits a proof, the board reviews that proof,\n") b.WriteString("and the verdict that carries also moves the coins.\n\n") for i, m := range r.Milestones { b.WriteString("### " + strconv.Itoa(i+1) + ". " + ui.Inline(m.Title) + ": " + rr.amount(m.Amount) + "\n\n") b.WriteString(milestoneState(r, i, m) + "\n\n") for j, a := range m.Attempts { b.WriteString("**Proof " + strconv.Itoa(j+1) + "** (" + a.Proof.Kind + ", height " + i64(a.Proof.Height) + ", " + a.Outcome.String() + ")\n\n") b.WriteString(fence(a.Proof.Ref) + "\n\n") if a.Proof.Note != "" { b.WriteString(block(a.Proof.Note) + "\n\n") } if len(a.Reviews) == 0 { b.WriteString("No review yet.\n\n") continue } b.WriteString(rr.ballots(a.Reviews, "accept", "refuse")) } } return b.String() } func (rr Renderer) ledger() string { p := rr.Program var b strings.Builder b.WriteString("# " + rr.Title + ": the ledger\n\n") b.WriteString("Every coin in and every coin out, in the order it happened. Coins sent\n") b.WriteString("straight to " + ui.AddrFull(rr.Treasury) + " land in the treasury\n") b.WriteString("without appearing here, which is why `Fund` exists: it is the same transfer\n") b.WriteString("with a name attached.\n\n") b.WriteString("## In\n\n") if len(p.Donations) == 0 { b.WriteString("Nothing donated through `Fund` yet.\n\n") } else { b.WriteString("| Height | From | Amount |\n|---|---|---|\n") for _, d := range p.Donations { b.WriteString("| " + i64(d.Height) + " | " + ui.AddrFull(d.From) + " | " + rr.amount(d.Amount) + " |\n") } b.WriteString("\nTotal in: " + rr.amount(p.Raised()) + "\n\n") } b.WriteString("## Out\n\n") if len(p.Payments) == 0 { b.WriteString("No tranche has been released yet.\n\n") } else { b.WriteString("| Height | Request | Milestone | To | Amount |\n|---|---|---|---|---|\n") for _, pay := range p.Payments { b.WriteString("| " + i64(pay.Height) + " | [" + strconv.Itoa(pay.Request) + "](" + rr.Link + ":request/" + strconv.Itoa(pay.Request) + ")" + " | " + strconv.Itoa(pay.Milestone+1) + " | " + ui.AddrFull(pay.To) + " | " + rr.amount(pay.Amount) + " |\n") } b.WriteString("\nTotal out: " + rr.amount(p.Disbursed()) + "\n\n") } b.WriteString("Balance now: " + rr.amount(rr.Balance) + ". [Back to the board](" + rr.Link + ").\n") return b.String() } // ballots renders one decision's ballots, marking anyone who has since left // the board: their vote stays on the record and stops counting. func (rr Renderer) ballots(bs []Ballot, forLabel, againstLabel string) string { var b strings.Builder b.WriteString("| Member | Vote | Height | Reason |\n|---|---|---|---|\n") for _, v := range bs { who := ui.AddrFull(v.Voter) if !rr.Program.Board.IsMember(v.Voter) { who += " (left the board)" } vote := againstLabel if v.Approve { vote = forLabel } b.WriteString("| " + who + " | " + vote + " | " + i64(v.Height) + " | " + orDash(ui.Cell(v.Reason)) + " |\n") } b.WriteString("\n") return b.String() } func (rr Renderer) amount(n int64) string { return strconv.FormatInt(n, 10) + " " + rr.Program.Denom } // who is the board index's "For" column: who this request would pay, marked // when that is not the address that filed it. func (rr Renderer) who(r *Request) string { if r.Kind == KindMember { return "-" } if r.OnBehalf() { return ui.Addr(r.Beneficiary) + " (filed by " + ui.AddrText(r.Applicant) + ")" } return ui.Addr(r.Applicant) } func (rr Renderer) ask(r *Request) string { if r.Kind == KindMember { return "nothing" } return rr.amount(r.Total()) } func milestoneState(r *Request, i int, m *Milestone) string { switch { case m.Released: return "Released at height " + i64(m.ReleasedAt) + ", paid to " + ui.AddrFull(r.Payee()) + "." case r.Status != Approved: return "Locked: the request is " + r.Status.String() + "." case m.Current() != nil: return "A proof is under review." case i == r.Next(): return "Waiting on a proof from the applicant." } return "Locked until milestone " + strconv.Itoa(r.Next()+1) + " is released." } func addRemove(add bool) string { if add { return "add to the board" } return "remove from the board" } // block and fence escape a multi-line, user-written string for its slot and // then trim the sanitizer's own trailing newlines. // // The trim is not cosmetic. gno collapses two consecutive blank lines in an // Example's output the way Go does, so any page that emits one can never be // pinned by an ExampleRender, and the only test that proves a whole page is // the one that stops working. func block(s string) string { return strings.TrimSpace(sanitize.Block(s)) } func fence(s string) string { return strings.TrimSpace(sanitize.CodeBlock(s)) } func i64(n int64) string { return strconv.FormatInt(n, 10) } func orDash(s string) string { if s == "" { return "-" } return s }
  18. #18render_test.gno
  19. #19package grants import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( erin = testutils.TestAddress("erin") // applicant, mid-delivery frank = testutils.TestAddress("frank") // donor grace = testutils.TestAddress("grace") // beneficiary of a request she did not file ) // demo drives a program through every state the renderer has to show: a // membership change that executed, a donation, a grant carried over one // dissent, a milestone released and paid, a proof the board refused, a second // attempt still under review, and a request turned down flat. // // It uses the library's own API only, so if a rule changes under it the // pinned pages below move and the diff says exactly what changed. func demo() *Program { p := NewProgram("ugnot", alice, bob, carol) p.Fund(frank, 3000, 100) m, _ := p.Board.SubmitMemberChange(alice, dave, true, "Reviewed six of the last eight applications from the outside. Give them the seat.", 100) p.Board.Vote(alice, m.ID, true, "Proposed it.", 101) p.Board.Vote(bob, m.ID, true, "No objection.", 102) g, _ := p.Apply(erin, "Port the p/nt/avl benchmarks to gno", "Three weeks. A benchmark harness, a report on the tree rebalancing costs, "+ "and a PR against the monorepo.", "harness merged:1200,report published:800", 110) p.Board.Vote(alice, g.ID, true, "Cheap for what it tells us.", 111) p.Board.Vote(bob, g.ID, false, "The second milestone is vague.", 112) p.Board.Vote(carol, g.ID, true, "Vague but bounded. Worth it.", 113) p.Board.Vote(dave, g.ID, true, "Agreed with carol.", 114) p.Board.SubmitProof(erin, g.ID, 0, Proof{ Kind: "url", Ref: "https://github.com/gnolang/gno/pull/9999", Note: "Harness merged, 14 benchmarks, runs in CI.", Height: 120, }) p.Review(alice, g.ID, 0, true, "Merged, I reviewed it.", 121, 3000) p.Review(carol, g.ID, 0, true, "Confirmed.", 122, 3000) p.Review(dave, g.ID, 0, true, "Confirmed.", 123, 3000) p.Board.SubmitProof(erin, g.ID, 1, Proof{Kind: "text", Ref: "it is basically done", Height: 130}) p.Review(alice, g.ID, 1, false, "That is not a report.", 131, 1800) p.Review(bob, g.ID, 1, false, "Agreed, no numbers.", 132, 1800) p.Review(carol, g.ID, 1, false, "Same.", 133, 1800) p.Board.SubmitProof(erin, g.ID, 1, Proof{ Kind: "url", Ref: "https://example.com/avl-rebalancing-costs", Note: "Rewritten with the measurements.", Height: 140, }) p.Review(alice, g.ID, 1, true, "Much better.", 141, 1800) o, _ := p.ApplyFor(erin, grace, "Her account is empty, so she cannot pay the gas to file this herself.", "Translate the onboarding guide to Portuguese", "One pass over the eight pages, reviewed by a second speaker.", "translation merged:600", 145) p.Board.Vote(alice, o.ID, true, "Cheap, and we have no pt-BR page at all.", 146) p.Board.Vote(bob, o.ID, true, "Fine.", 147) p.Board.Vote(carol, o.ID, true, "Fine.", 148) n, _ := p.Apply(mallory, "Rewrite gnoweb in a different framework", "Six months, one person, no migration plan.", "the rewrite:250000", 150) p.Board.Vote(alice, n.ID, false, "No migration plan and no second maintainer.", 151) p.Board.Vote(bob, n.ID, false, "Out of scope for this board.", 152) p.Board.Vote(carol, n.ID, false, "Same.", 153) return p } // view wraps demo in a Renderer configured the way a realm would. Balance is // 3000 donated minus the 1200 tranche already paid. func view() Renderer { return Renderer{ Program: demo(), Title: "Example grant board", Intro: "Anyone may ask this board for money.", Path: "gno.land/r/example/board/v0", Link: "/r/example/board/v0", Treasury: testutils.TestAddress("treasury"), Balance: 1800, Footer: "Built on [p/moul/grants/v0](/p/moul/grants/v0).", } } func TestDemoReachedEveryStateTheseExamplesPin(t *testing.T) { p := demo() uassert.Equal(t, 4, p.Board.MemberCount(), "the membership change executed") uassert.Equal(t, 4, p.Board.Size()) uassert.Equal(t, "completed", p.Board.Get(1).Status.String()) uassert.Equal(t, "approved", p.Board.Get(2).Status.String()) uassert.Equal(t, "approved", p.Board.Get(3).Status.String()) uassert.Equal(t, "rejected", p.Board.Get(4).Status.String()) o := p.Board.Get(3) uassert.True(t, o.OnBehalf(), "request 3 is filed by erin for grace") uassert.Equal(t, grace.String(), o.Payee().String()) g := p.Board.Get(2) uassert.True(t, g.Milestones[0].Released, "one tranche paid") uassert.Equal(t, int64(1200), p.Disbursed()) uassert.Equal(t, int64(1400), p.Committed(), "800 still owed on 2, 600 on 3") uassert.Equal(t, int64(3000), p.Raised()) uassert.Equal(t, 1, len(p.Payments)) uassert.Equal(t, 2, len(g.Milestones[1].Attempts), "one refused, one in flight") uassert.Equal(t, "refused", g.Milestones[1].Attempts[0].Outcome.String()) uassert.Equal(t, "under review", g.Milestones[1].Attempts[1].Outcome.String()) } // Deliberately over an EMPTY program: the routing is what is under test, and // rendering the full demo four times costs seconds of VM time for nothing. func TestRenderUnknownPaths(t *testing.T) { v := Renderer{Program: NewProgram("ugnot", alice), Title: "Example grant board", Link: "/r/x/v0"} uassert.Equal(t, "# Example grant board\n\nThere is no request 99.\n", v.Render("request/99")) uassert.Equal(t, "# Example grant board\n\n`abc` is not a request number.\n", v.Render("request/abc")) uassert.True(t, len(v.Render("nope")) > 0) uassert.Equal(t, v.Render(""), v.Render("/"), "the root is the root either way") } // ExampleRendererIndex pins the board page. func ExampleRendererIndex() { print(view().Render("")) // Output: // # Example grant board // // Anyone may ask this board for money. // // ## Treasury // // | Field | Value | // |---|---| // | Address | `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` | // | Balance | 1800 ugnot | // | Promised | 1400 ugnot | // | Unpromised | 400 ugnot | // | Donated | 3000 ugnot | // | Paid out | 1200 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 | // |---|---| // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | 102 | // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | 0 | // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | 0 | // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | 0 | // // ## Requests // // | # | Kind | Title | For | Asking | Status | Ballots | // |---|---|---|---|---|---|---| // | [1](/r/example/board/v0:request/1) | membership | Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l | - | nothing | completed | 2 for, 0 against | // | [2](/r/example/board/v0:request/2) | grant | Port the p/nt/avl benchmarks to gno | `g1v4exjm…sj8d` | 2000 ugnot | approved | 3 for, 1 against | // | [3](/r/example/board/v0:request/3) | grant | Translate the onboarding guide to Portuguese | `g1vaexzc…qsw9` (filed by g1v4exjm…sj8d) | 600 ugnot | approved | 3 for, 0 against | // | [4](/r/example/board/v0:request/4) | grant | Rewrite gnoweb in a different framework | `g1d4skcm…cwc9` | 250000 ugnot | rejected | 0 for, 3 against | // // ## Where the money went // // [The ledger](/r/example/board/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/example/board/v0 -func Fund -send 5000000ugnot ... // # ask for some // gnokey maketx call -pkgpath gno.land/r/example/board/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/example/board/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/example/board/v0 -func Vote -args 1 -args true -args 'reason' ... // # show your work, then get paid for it // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func SubmitProof \ // -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ... // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Review -args 1 -args 0 -args true -args 'looks done' ... // ``` // // Built on [p/moul/grants/v0](/p/moul/grants/v0). } // ExampleRendererRequest pins the richest page: a grant mid-delivery with one // tranche paid, a refused proof kept on the record, and a second attempt // still being reviewed. func ExampleRendererRequest() { print(view().Render("request/2")) // Output: // # Request 2: Port the p/nt/avl benchmarks to gno // // | Field | Value | // |---|---| // | Kind | grant | // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | // | Status | approved | // | Filed at height | 110 | // | Decided at height | 114 | // | Asking | 2000 ugnot | // | Paid so far | 1200 ugnot | // | Still owed | 800 ugnot | // // ## The ask // // // // Three weeks. A benchmark harness, a report on the tree rebalancing costs, and a PR against the monorepo. // // // // ## Ballots // // Needs 3 of 4 eligible members. Standing: 3 for, 1 against. // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 111 | Cheap for what it tells us\. | // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | against | 112 | The second milestone is vague\. | // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 113 | Vague but bounded\. Worth it\. | // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | for | 114 | Agreed with carol\. | // // ## Milestones // // Earned in order. The applicant submits a proof, the board reviews that proof, // and the verdict that carries also moves the coins. // // ### 1. harness merged: 1200 ugnot // // Released at height 123, paid to `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d`. // // **Proof 1** (url, height 120, accepted) // // ``` // https://github.com/gnolang/gno/pull/9999 // ``` // // // // Harness merged, 14 benchmarks, runs in CI. // // // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 121 | Merged, I reviewed it\. | // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | accept | 122 | Confirmed\. | // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | accept | 123 | Confirmed\. | // // ### 2. report published: 800 ugnot // // A proof is under review. // // **Proof 1** (text, height 130, refused) // // ``` // it is basically done // ``` // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | refuse | 131 | That is not a report\. | // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | refuse | 132 | Agreed, no numbers\. | // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | refuse | 133 | Same\. | // // **Proof 2** (url, height 140, under review) // // ``` // https://example.com/avl-rebalancing-costs // ``` // // // // Rewritten with the measurements. // // // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 141 | Much better\. | } // ExampleRendererMembership pins a membership request, which asks for no // money and therefore renders no milestones. func ExampleRendererMembership() { print(view().Render("request/1")) // Output: // # Request 1: Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l // // | Field | Value | // |---|---| // | Kind | membership | // | Applicant | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | // | Subject | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | // | Effect | add to the board | // | Status | completed | // | Filed at height | 100 | // | Decided at height | 102 | // // ## The ask // // // // Reviewed six of the last eight applications from the outside. Give them the seat. // // // // ## Ballots // // Needs 2 of 3 eligible members. Standing: 2 for, 0 against. // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 101 | Proposed it\. | // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 102 | No objection\. | } // ExampleRendererLedger pins the money trail. func ExampleRendererLedger() { print(view().Render("ledger")) // Output: // # Example grant board: the ledger // // Every coin in and every coin out, in the order it happened. Coins sent // straight to `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` land in the treasury // without appearing here, which is why `Fund` exists: it is the same transfer // with a name attached. // // ## In // // | Height | From | Amount | // |---|---|---| // | 100 | `g1veexzmntta047h6lta047h6lta047h6lthxxu5` | 3000 ugnot | // // Total in: 3000 ugnot // // ## Out // // | Height | Request | Milestone | To | Amount | // |---|---|---|---|---| // | 123 | [2](/r/example/board/v0:request/2) | 1 | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | 1200 ugnot | // // Total out: 1200 ugnot // // Balance now: 1800 ugnot. [Back to the board](/r/example/board/v0). } // ExampleRendererOnBehalf pins a request one address filed for another: the // beneficiary row, the callout carrying the applicant's reason, and the "For" // column that says on the board page who the money would reach. func ExampleRendererOnBehalf() { print(view().Render("request/3")) // Output: // # Request 3: Translate the onboarding guide to Portuguese // // | Field | Value | // |---|---| // | Kind | grant | // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | // | Beneficiary | `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9` | // | Status | approved | // | Filed at height | 145 | // | Decided at height | 148 | // | Asking | 600 ugnot | // | Paid so far | 0 ugnot | // | Still owed | 600 ugnot | // // ## Filed for someone else // // The applicant is not the payee: every tranche of this request pays // `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9`. Both addresses are barred from voting on it, and // either may submit a proof. The reason given: // // > Her account is empty, so she cannot pay the gas to file this herself\. // // Nothing checks that reason. It is a claim by the applicant, and the point of // printing it here is that a member can check it before voting. // // ## The ask // // One pass over the eight pages, reviewed by a second speaker. // // ## Ballots // // Needs 3 of 4 eligible members. Standing: 3 for, 0 against. // // | Member | Vote | Height | Reason | // |---|---|---|---| // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 146 | Cheap, and we have no pt\-BR page at all\. | // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 147 | Fine\. | // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 148 | Fine\. | // // ## Milestones // // Earned in order. The applicant submits a proof, the board reviews that proof, // and the verdict that carries also moves the coins. // // ### 1. translation merged: 600 ugnot // // Waiting on a proof from the applicant. } // injected is every dangerous markdown sequence a caller can type, in one // string, so a slot that forgets to escape shows up as a live link or a // gnoweb tag in the assertions below rather than on a deployed page. const injected = "[click](https://evil.example) ![x](https://evil.example/p.png) " + "<gno-columns> | fake | cell | ```\nsecond line\n" // injectedShort is the same attack inside the 100-character limit a title and // a milestone name are held to. const injectedShort = "[click](https://evil.example) <gno-columns>|x|\nnext" // TestEveryCallerSuppliedStringIsEscaped drives one board through every slot // a caller controls and asserts that none of them can reshape the page. // // The bar is not the same in every slot, on purpose. A one-line slot (a // title, a milestone name, the reason on a ballot, the reason for filing on // someone else's behalf) is chrome the page lays out, so nothing a caller // typed may survive as markup there. A prose slot (the body of an // application, the note on a proof) goes through sanitize.Block, which // PRESERVES inline links and emphasis by design: a grant application whose // link to the merged PR renders as literal text is a worse page, and the // sanitizer's own contract is that prose formats. What Block still kills is // everything structural, so the paragraph cannot leave its paragraph. // // Assertions are on the dangerous SEQUENCE, never on the exact escaped bytes: // those belong to the sanitizer, they change when it changes, and a test that // pins them fails for the wrong reason. func TestEveryCallerSuppliedStringIsEscaped(t *testing.T) { p := NewProgram("ugnot", alice, bob) r, err := p.ApplyFor(erin, grace, injected, injectedShort, injected, injectedShort+":100", 10) urequire.NoError(t, err) p.Board.Vote(alice, r.ID, true, injected, 11) p.Board.Vote(bob, r.ID, true, injected, 12) urequire.NoError(t, p.Board.SubmitProof(erin, r.ID, 0, Proof{ Kind: "url", Ref: injected, Note: injected, Height: 13, })) p.Board.Review(alice, r.ID, 0, false, injected, 14) v := Renderer{Program: p, Title: "Board", Path: "gno.land/r/x/v0", Link: "/r/x/v0", Balance: 0} // Every structural line of every page: a table row, a heading, a // blockquote. Nothing a caller typed may render as markup in one. for _, page := range []string{"", "request/1", "ledger"} { for _, line := range strings.Split(v.Render(page), "\n") { if !strings.HasPrefix(line, "|") && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ">") { continue } for _, dead := range []string{"](http", "![", "<gno-"} { uassert.False(t, strings.Contains(line, dead), "page "+page+" line still carries "+dead+": "+line) } } } // A table row keeps exactly its own cells: an unescaped pipe would open a // column and an unescaped newline would end the row early, and both // reshape the table silently rather than failing. for _, line := range strings.Split(v.Render("request/1"), "\n") { if !strings.HasPrefix(line, "| `"+alice.String()+"`") { continue } uassert.Equal(t, 5, strings.Count(line, "|")-strings.Count(line, "\\|"), "the ballot row kept its four cells: "+line) } // An escaper that ate the content would pass every assertion above. out := v.Render("request/1") uassert.True(t, strings.Contains(out, "click"), "the readable text survived") uassert.True(t, strings.Contains(out, "second line"), "the second line survived") // The structural attack is dead in the prose slots too, even though the // link in them is deliberately alive. body := out[strings.Index(out, "## The ask"):strings.Index(out, "## Ballots")] uassert.False(t, strings.Contains(body, "\n<gno-"), "no gnoweb tag at a line start") uassert.False(t, strings.Contains(body, "\n| "), "prose cannot open a table") uassert.False(t, strings.Contains(body, "\n#"), "prose cannot forge a heading") }
  20. #20/gno.MemPackageType
  21. #21 MPUserAll

Result log

msg:0,success:true,log:,events:[]

← Back to block 271,992