grants.gno
13.87 Kb · 454 lines
1// Package grants is a grant board governed by a member set: anyone may ask
2// the board for money, the members vote in the open, and the money leaves in
3// tranches that each have to be earned by producing a proof the members
4// accept.
5//
6// The package is pure. It holds no coins, reads no chain state and imports
7// nothing from `chain`: the caller supplies the acting address and the block
8// height, and the caller performs the transfer. That is what makes the whole
9// state machine unit-testable without a node, and it is what lets the same
10// board be driven by a realm, by a test, or by a different payment rail.
11// Live board: gno.land/r/moul/grant/v0.
12//
13// # The shape of a grant
14//
15// A Request is a title, a body, and an ordered list of Milestones, each with
16// its own amount. Approving a request does not pay anything: it only makes the
17// first milestone claimable. To get a tranche the applicant submits a Proof
18// (a URL, a hash, or plain text) and the members review that specific proof.
19// A rejected proof does not kill the grant, it just closes one Attempt; the
20// applicant submits another. Every attempt, accepted or not, stays on the
21// record with the ballots that decided it.
22//
23// # Asking for someone else
24//
25// A request has an applicant, who files it and answers for it, and a
26// beneficiary, who gets paid. SubmitFor separates the two and takes a reason
27// for the detour; Submit is the common case where they are the same address.
28//
29// The split is not a convenience. An account with nothing in it cannot pay the
30// gas to ask for its first coins, so on a board whose whole purpose is to fund
31// empty accounts, "someone else files it" is the ONLY path that works. Both
32// addresses are then barred from voting on the request, and either of them may
33// submit a proof, for the same reason: the payee may not be able to transact
34// at all until the first tranche lands.
35//
36// # Who may vote
37//
38// Every member, once, per decision. The party the decision is about is
39// excluded: the applicant does not vote on their own grant, and the subject of
40// a membership change does not vote on their own membership. The bar is a
41// majority of the addresses that are actually eligible, recomputed on each
42// ballot, so a board that grows mid-vote raises its own bar.
43//
44// # What this deliberately does not do
45//
46// No weights, no delegation, no quadratic anything, no deadline. A request
47// with no majority either way simply stays Pending until someone breaks the
48// tie or the applicant withdraws it. Those are all reasonable things to add on
49// top; none of them is needed to show the shape.
50package grants
51
52import (
53 "errors"
54 "strconv"
55
56 "gno.land/p/nt/avl/v0"
57)
58
59// Errors returned by the mutators. They are sentinels so a realm can map them
60// onto its own messages, or simply panic with them.
61var (
62 ErrNotMember = errors.New("grants: not a board member")
63 ErrNotApplicant = errors.New("grants: only the applicant may do this")
64 ErrNoRequest = errors.New("grants: no such request")
65 ErrNoMilestone = errors.New("grants: no such milestone")
66 ErrNotPending = errors.New("grants: the request is not pending")
67 ErrNotApproved = errors.New("grants: the request is not approved")
68 ErrAlreadyVoted = errors.New("grants: this member already voted")
69 ErrConflict = errors.New("grants: the party a decision is about may not vote on it")
70 ErrOutOfOrder = errors.New("grants: milestones are claimed in order")
71 ErrProofPending = errors.New("grants: a proof is already under review")
72 ErrNoProof = errors.New("grants: no proof is under review")
73 ErrBadTitle = errors.New("grants: title must be 1 to 100 characters")
74 ErrBadBody = errors.New("grants: body must be at most 2000 characters")
75 ErrBadMilestones = errors.New("grants: a grant needs 1 to 10 milestones")
76 ErrBadAmount = errors.New("grants: every milestone must ask for a positive amount")
77 ErrBadProof = errors.New("grants: proof kind must be url, hash or text, with a reference")
78 ErrBadReason = errors.New("grants: reason must be at most 300 characters")
79 ErrNeedReason = errors.New("grants: filing for someone else needs a reason")
80 ErrBadAddress = errors.New("grants: invalid address")
81 ErrIsMember = errors.New("grants: already a board member")
82 ErrLastMember = errors.New("grants: the last member cannot be removed")
83 ErrNothingSent = errors.New("grants: a donation must be a positive amount")
84 ErrUnderfunded = errors.New("grants: the treasury cannot cover this tranche")
85 ErrBadSpec = errors.New("grants: milestones must read \"title:amount,title:amount\"")
86)
87
88// IndexRequests is how many requests a board's page lists.
89//
90// Finding 325-cost-at-caps-1: the page rendered every request ever made, and
91// Apply is open to anyone with no removal path, so the cost every reader paid
92// was set by whoever had applied the most.
93const IndexRequests = 20
94
95// Limits every input is checked against. Exported so a UI can pre-validate.
96const (
97 MaxTitle = 100
98 MaxBody = 2000
99 MaxReason = 300
100 MaxRef = 300
101 MaxNote = 500
102 MaxMilestones = 10
103)
104
105// Status is where a request sits in its lifecycle.
106type Status int
107
108const (
109 Pending Status = iota // open for member ballots
110 Approved // carried; milestones can be earned
111 Rejected // a majority voted no
112 Completed // every milestone released
113 Withdrawn // the applicant pulled it before a decision
114)
115
116func (s Status) String() string {
117 switch s {
118 case Pending:
119 return "pending"
120 case Approved:
121 return "approved"
122 case Rejected:
123 return "rejected"
124 case Completed:
125 return "completed"
126 case Withdrawn:
127 return "withdrawn"
128 }
129 return "unknown"
130}
131
132// Kind says what carrying a request actually does.
133type Kind int
134
135const (
136 KindGrant Kind = iota // pays out, milestone by milestone
137 KindMember // adds or removes a board member, immediately
138)
139
140func (k Kind) String() string {
141 if k == KindMember {
142 return "membership"
143 }
144 return "grant"
145}
146
147// Outcome is the result of one proof attempt.
148type Outcome int
149
150const (
151 UnderReview Outcome = iota // members are still voting on this proof
152 Accepted // the tranche is earned
153 Refused // this proof did not convince; another may
154)
155
156func (o Outcome) String() string {
157 switch o {
158 case Accepted:
159 return "accepted"
160 case Refused:
161 return "refused"
162 }
163 return "under review"
164}
165
166// Ballot is one member's vote on one decision, with the reason they gave.
167// Reasons are not optional decoration: they are the only part of a vote that
168// tells an applicant what to fix.
169type Ballot struct {
170 Voter address
171 Approve bool
172 Reason string
173 Height int64
174}
175
176// Proof is the evidence an applicant offers for one milestone. Kind is "url",
177// "hash" or "text"; the board decides what it is worth.
178type Proof struct {
179 Kind string
180 Ref string
181 Note string
182 Height int64
183}
184
185// Attempt is one proof and the review it got. A milestone keeps every attempt,
186// so a refused proof and the reasons it was refused stay readable forever.
187type Attempt struct {
188 Proof Proof
189 Reviews []Ballot
190 Outcome Outcome
191 ClosedAt int64
192}
193
194// Milestone is one tranche of a grant: what has to be shown, and what it pays.
195type Milestone struct {
196 Title string
197 Amount int64 // in the smallest unit of whatever the realm pays in
198 Attempts []*Attempt
199 Released bool
200 ReleasedAt int64
201}
202
203// Current returns the attempt still under review, or nil.
204func (m *Milestone) Current() *Attempt {
205 if len(m.Attempts) == 0 {
206 return nil
207 }
208 last := m.Attempts[len(m.Attempts)-1]
209 if last.Outcome == UnderReview {
210 return last
211 }
212 return nil
213}
214
215// Request is a grant application or a membership change.
216type Request struct {
217 ID int
218 Kind Kind
219 Applicant address // who filed it and answers for it
220 Beneficiary address // who gets paid; equal to Applicant on a self request
221 Reason string // why the applicant is asking on someone else's behalf
222 Title string
223 Body string
224 Milestones []*Milestone
225 Subject address // KindMember only: the address being added or removed
226 Add bool // KindMember only: true adds, false removes
227 Votes []Ballot
228 Status Status
229 CreatedAt int64
230 DecidedAt int64
231}
232
233// OnBehalf reports whether this grant was filed by one address for another.
234func (r *Request) OnBehalf() bool {
235 return r.Kind == KindGrant && r.Beneficiary != r.Applicant
236}
237
238// Payee is the address a released tranche pays. It is the beneficiary, which
239// is the applicant unless the request was filed for someone else.
240func (r *Request) Payee() address {
241 if r.Beneficiary.IsValid() {
242 return r.Beneficiary
243 }
244 return r.Applicant
245}
246
247// Total is the sum of every milestone, released or not.
248func (r *Request) Total() int64 {
249 var n int64
250 for _, m := range r.Milestones {
251 n += m.Amount
252 }
253 return n
254}
255
256// Paid is what the milestones released so far add up to.
257func (r *Request) Paid() int64 {
258 var n int64
259 for _, m := range r.Milestones {
260 if m.Released {
261 n += m.Amount
262 }
263 }
264 return n
265}
266
267// Outstanding is what an approved request can still cost the treasury. It is
268// zero for anything not approved, which is what makes it safe to sum across
269// the board as the committed amount.
270func (r *Request) Outstanding() int64 {
271 if r.Status != Approved {
272 return 0
273 }
274 return r.Total() - r.Paid()
275}
276
277// Next is the index of the first unreleased milestone, or -1 when the grant is
278// fully paid. Milestones are earned in order.
279func (r *Request) Next() int {
280 for i, m := range r.Milestones {
281 if !m.Released {
282 return i
283 }
284 }
285 return -1
286}
287
288// Tally counts the ballots cast on the request itself.
289func (r *Request) Tally() (yes, no int) {
290 return count(r.Votes)
291}
292
293// BallotOf returns the member's ballot on the request, or nil.
294func (r *Request) BallotOf(voter address) *Ballot {
295 return find(r.Votes, voter)
296}
297
298// Excludes reports whether addr is barred from voting on this request because
299// the decision is about them.
300//
301// A grant excludes BOTH parties, not just the one who typed the transaction:
302// letting a member be paid by a request a friend filed for them is the same
303// conflict with one more step in it.
304func (r *Request) Excludes(addr address) bool {
305 if r.Kind == KindMember {
306 return addr == r.Subject
307 }
308 return addr == r.Applicant || addr == r.Payee()
309}
310
311func count(bs []Ballot) (yes, no int) {
312 for _, b := range bs {
313 if b.Approve {
314 yes++
315 } else {
316 no++
317 }
318 }
319 return yes, no
320}
321
322func find(bs []Ballot, voter address) *Ballot {
323 for i := range bs {
324 if bs[i].Voter == voter {
325 return &bs[i]
326 }
327 }
328 return nil
329}
330
331// Board is the whole state of one grant program: who decides, and what has
332// been asked of them.
333type Board struct {
334 members *avl.Tree // address string -> int64 join height
335 requests *avl.Tree // zero-padded id -> *Request
336 nextID int
337}
338
339// New returns a board whose founding members are the given addresses, all
340// recorded as having joined at height 0. Duplicates and invalid addresses are
341// skipped; a board with no members accepts no decisions until one is added, so
342// callers normally pass at least one.
343func New(founders ...address) *Board {
344 b := &Board{members: avl.NewTree(), requests: avl.NewTree(), nextID: 1}
345 for _, f := range founders {
346 if f.IsValid() {
347 b.members.Set(f.String(), int64(0))
348 }
349 }
350 return b
351}
352
353// IsMember reports whether addr sits on the board.
354func (b *Board) IsMember(addr address) bool { return b.members.Has(addr.String()) }
355
356// MemberCount is the number of addresses on the board.
357func (b *Board) MemberCount() int { return b.members.Size() }
358
359// Members lists the board in address order, which is stable across calls.
360func (b *Board) Members() []address {
361 out := make([]address, 0, b.members.Size())
362 b.members.Iterate("", "", func(k string, _ any) bool {
363 out = append(out, address(k))
364 return false
365 })
366 return out
367}
368
369// JoinedAt returns the height a member joined, or -1 for a non-member.
370func (b *Board) JoinedAt(addr address) int64 {
371 v := b.members.Get(addr.String())
372 if v == nil {
373 return -1
374 }
375 return v.(int64)
376}
377
378// Eligible is how many members may vote on a request: everyone but the parties
379// the decision is about.
380func (b *Board) Eligible(r *Request) int {
381 n := 0
382 b.members.Iterate("", "", func(k string, _ any) bool {
383 if !r.Excludes(address(k)) {
384 n++
385 }
386 return false
387 })
388 return n
389}
390
391// Majority is the number of concurring ballots a decision on r needs. It is
392// recomputed on every ballot, so a board that changes size mid-vote moves its
393// own bar rather than freezing a stale one.
394func (b *Board) Majority(r *Request) int {
395 n := b.Eligible(r)
396 if n <= 0 {
397 return 1 // an unreachable bar beats a bar of zero
398 }
399 return n/2 + 1
400}
401
402// Get returns a request by id, or nil.
403func (b *Board) Get(id int) *Request {
404 v := b.requests.Get(idKey(id))
405 if v == nil {
406 return nil
407 }
408 return v.(*Request)
409}
410
411// Size is the number of requests ever filed.
412func (b *Board) Size() int { return b.requests.Size() }
413
414// List returns every request in id order.
415func (b *Board) List() []*Request {
416 out := make([]*Request, 0, b.requests.Size())
417 b.requests.Iterate("", "", func(_ string, v any) bool {
418 out = append(out, v.(*Request))
419 return false
420 })
421 return out
422}
423
424// Committed is what the treasury still owes on approved grants. A realm that
425// keeps its balance at or above this number can always pay a released tranche.
426func (b *Board) Committed() int64 {
427 var n int64
428 b.requests.Iterate("", "", func(_ string, v any) bool {
429 n += v.(*Request).Outstanding()
430 return false
431 })
432 return n
433}
434
435// Disbursed is everything the board has ever released.
436func (b *Board) Disbursed() int64 {
437 var n int64
438 b.requests.Iterate("", "", func(_ string, v any) bool {
439 n += v.(*Request).Paid()
440 return false
441 })
442 return n
443}
444
445// idKey pads an id so avl iteration is numeric, not lexicographic: unpadded
446// keys sort "1","10","2" and every listing would lose its order past nine
447// entries. ufmt has no width flags in gno, so the padding is by hand.
448func idKey(id int) string {
449 s := strconv.Itoa(id)
450 for len(s) < 8 {
451 s = "0" + s
452 }
453 return s
454}