Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

/p/moul/grants/v0

Directory · 9 Files
README.md Open

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, 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 Milestones, 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

 1p := grants.NewProgram("ugnot", alice, bob, carol)
 2
 3p.Fund(donor, 5000, height)                 // put a name on a transfer that already happened
 4r, _ := p.Apply(dave, "Port the thing", "why it matters", "design:100,ship:400", height)
 5
 6p.Board.Vote(alice, r.ID, true, "cheap for what it tells us", height)
 7p.Board.Vote(bob, r.ID, true, "agreed", height)          // majority of three: approved
 8
 9p.Board.SubmitProof(dave, r.ID, 0, grants.Proof{Kind: "url", Ref: "https://…", Height: height})
10p.Review(alice, r.ID, 0, true, "merged, I reviewed it", height, balance)
11out, pay, err := p.Review(carol, r.ID, 0, true, "confirmed", height, balance)
12if pay != nil {
13    // out == grants.Accepted. Send pay.Amount to pay.To; it is already on the ledger.
14}

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 and p/nt/markdown/sanitize 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.


Part of 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

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.