grants.gno
13.59 Kb · 447 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// Limits every input is checked against. Exported so a UI can pre-validate.
89const (
90 MaxTitle = 100
91 MaxBody = 2000
92 MaxReason = 300
93 MaxRef = 300
94 MaxNote = 500
95 MaxMilestones = 10
96)
97
98// Status is where a request sits in its lifecycle.
99type Status int
100
101const (
102 Pending Status = iota // open for member ballots
103 Approved // carried; milestones can be earned
104 Rejected // a majority voted no
105 Completed // every milestone released
106 Withdrawn // the applicant pulled it before a decision
107)
108
109func (s Status) String() string {
110 switch s {
111 case Pending:
112 return "pending"
113 case Approved:
114 return "approved"
115 case Rejected:
116 return "rejected"
117 case Completed:
118 return "completed"
119 case Withdrawn:
120 return "withdrawn"
121 }
122 return "unknown"
123}
124
125// Kind says what carrying a request actually does.
126type Kind int
127
128const (
129 KindGrant Kind = iota // pays out, milestone by milestone
130 KindMember // adds or removes a board member, immediately
131)
132
133func (k Kind) String() string {
134 if k == KindMember {
135 return "membership"
136 }
137 return "grant"
138}
139
140// Outcome is the result of one proof attempt.
141type Outcome int
142
143const (
144 UnderReview Outcome = iota // members are still voting on this proof
145 Accepted // the tranche is earned
146 Refused // this proof did not convince; another may
147)
148
149func (o Outcome) String() string {
150 switch o {
151 case Accepted:
152 return "accepted"
153 case Refused:
154 return "refused"
155 }
156 return "under review"
157}
158
159// Ballot is one member's vote on one decision, with the reason they gave.
160// Reasons are not optional decoration: they are the only part of a vote that
161// tells an applicant what to fix.
162type Ballot struct {
163 Voter address
164 Approve bool
165 Reason string
166 Height int64
167}
168
169// Proof is the evidence an applicant offers for one milestone. Kind is "url",
170// "hash" or "text"; the board decides what it is worth.
171type Proof struct {
172 Kind string
173 Ref string
174 Note string
175 Height int64
176}
177
178// Attempt is one proof and the review it got. A milestone keeps every attempt,
179// so a refused proof and the reasons it was refused stay readable forever.
180type Attempt struct {
181 Proof Proof
182 Reviews []Ballot
183 Outcome Outcome
184 ClosedAt int64
185}
186
187// Milestone is one tranche of a grant: what has to be shown, and what it pays.
188type Milestone struct {
189 Title string
190 Amount int64 // in the smallest unit of whatever the realm pays in
191 Attempts []*Attempt
192 Released bool
193 ReleasedAt int64
194}
195
196// Current returns the attempt still under review, or nil.
197func (m *Milestone) Current() *Attempt {
198 if len(m.Attempts) == 0 {
199 return nil
200 }
201 last := m.Attempts[len(m.Attempts)-1]
202 if last.Outcome == UnderReview {
203 return last
204 }
205 return nil
206}
207
208// Request is a grant application or a membership change.
209type Request struct {
210 ID int
211 Kind Kind
212 Applicant address // who filed it and answers for it
213 Beneficiary address // who gets paid; equal to Applicant on a self request
214 Reason string // why the applicant is asking on someone else's behalf
215 Title string
216 Body string
217 Milestones []*Milestone
218 Subject address // KindMember only: the address being added or removed
219 Add bool // KindMember only: true adds, false removes
220 Votes []Ballot
221 Status Status
222 CreatedAt int64
223 DecidedAt int64
224}
225
226// OnBehalf reports whether this grant was filed by one address for another.
227func (r *Request) OnBehalf() bool {
228 return r.Kind == KindGrant && r.Beneficiary != r.Applicant
229}
230
231// Payee is the address a released tranche pays. It is the beneficiary, which
232// is the applicant unless the request was filed for someone else.
233func (r *Request) Payee() address {
234 if r.Beneficiary.IsValid() {
235 return r.Beneficiary
236 }
237 return r.Applicant
238}
239
240// Total is the sum of every milestone, released or not.
241func (r *Request) Total() int64 {
242 var n int64
243 for _, m := range r.Milestones {
244 n += m.Amount
245 }
246 return n
247}
248
249// Paid is what the milestones released so far add up to.
250func (r *Request) Paid() int64 {
251 var n int64
252 for _, m := range r.Milestones {
253 if m.Released {
254 n += m.Amount
255 }
256 }
257 return n
258}
259
260// Outstanding is what an approved request can still cost the treasury. It is
261// zero for anything not approved, which is what makes it safe to sum across
262// the board as the committed amount.
263func (r *Request) Outstanding() int64 {
264 if r.Status != Approved {
265 return 0
266 }
267 return r.Total() - r.Paid()
268}
269
270// Next is the index of the first unreleased milestone, or -1 when the grant is
271// fully paid. Milestones are earned in order.
272func (r *Request) Next() int {
273 for i, m := range r.Milestones {
274 if !m.Released {
275 return i
276 }
277 }
278 return -1
279}
280
281// Tally counts the ballots cast on the request itself.
282func (r *Request) Tally() (yes, no int) {
283 return count(r.Votes)
284}
285
286// BallotOf returns the member's ballot on the request, or nil.
287func (r *Request) BallotOf(voter address) *Ballot {
288 return find(r.Votes, voter)
289}
290
291// Excludes reports whether addr is barred from voting on this request because
292// the decision is about them.
293//
294// A grant excludes BOTH parties, not just the one who typed the transaction:
295// letting a member be paid by a request a friend filed for them is the same
296// conflict with one more step in it.
297func (r *Request) Excludes(addr address) bool {
298 if r.Kind == KindMember {
299 return addr == r.Subject
300 }
301 return addr == r.Applicant || addr == r.Payee()
302}
303
304func count(bs []Ballot) (yes, no int) {
305 for _, b := range bs {
306 if b.Approve {
307 yes++
308 } else {
309 no++
310 }
311 }
312 return yes, no
313}
314
315func find(bs []Ballot, voter address) *Ballot {
316 for i := range bs {
317 if bs[i].Voter == voter {
318 return &bs[i]
319 }
320 }
321 return nil
322}
323
324// Board is the whole state of one grant program: who decides, and what has
325// been asked of them.
326type Board struct {
327 members *avl.Tree // address string -> int64 join height
328 requests *avl.Tree // zero-padded id -> *Request
329 nextID int
330}
331
332// New returns a board whose founding members are the given addresses, all
333// recorded as having joined at height 0. Duplicates and invalid addresses are
334// skipped; a board with no members accepts no decisions until one is added, so
335// callers normally pass at least one.
336func New(founders ...address) *Board {
337 b := &Board{members: avl.NewTree(), requests: avl.NewTree(), nextID: 1}
338 for _, f := range founders {
339 if f.IsValid() {
340 b.members.Set(f.String(), int64(0))
341 }
342 }
343 return b
344}
345
346// IsMember reports whether addr sits on the board.
347func (b *Board) IsMember(addr address) bool { return b.members.Has(addr.String()) }
348
349// MemberCount is the number of addresses on the board.
350func (b *Board) MemberCount() int { return b.members.Size() }
351
352// Members lists the board in address order, which is stable across calls.
353func (b *Board) Members() []address {
354 out := make([]address, 0, b.members.Size())
355 b.members.Iterate("", "", func(k string, _ any) bool {
356 out = append(out, address(k))
357 return false
358 })
359 return out
360}
361
362// JoinedAt returns the height a member joined, or -1 for a non-member.
363func (b *Board) JoinedAt(addr address) int64 {
364 v := b.members.Get(addr.String())
365 if v == nil {
366 return -1
367 }
368 return v.(int64)
369}
370
371// Eligible is how many members may vote on a request: everyone but the parties
372// the decision is about.
373func (b *Board) Eligible(r *Request) int {
374 n := 0
375 b.members.Iterate("", "", func(k string, _ any) bool {
376 if !r.Excludes(address(k)) {
377 n++
378 }
379 return false
380 })
381 return n
382}
383
384// Majority is the number of concurring ballots a decision on r needs. It is
385// recomputed on every ballot, so a board that changes size mid-vote moves its
386// own bar rather than freezing a stale one.
387func (b *Board) Majority(r *Request) int {
388 n := b.Eligible(r)
389 if n <= 0 {
390 return 1 // an unreachable bar beats a bar of zero
391 }
392 return n/2 + 1
393}
394
395// Get returns a request by id, or nil.
396func (b *Board) Get(id int) *Request {
397 v := b.requests.Get(idKey(id))
398 if v == nil {
399 return nil
400 }
401 return v.(*Request)
402}
403
404// Size is the number of requests ever filed.
405func (b *Board) Size() int { return b.requests.Size() }
406
407// List returns every request in id order.
408func (b *Board) List() []*Request {
409 out := make([]*Request, 0, b.requests.Size())
410 b.requests.Iterate("", "", func(_ string, v any) bool {
411 out = append(out, v.(*Request))
412 return false
413 })
414 return out
415}
416
417// Committed is what the treasury still owes on approved grants. A realm that
418// keeps its balance at or above this number can always pay a released tranche.
419func (b *Board) Committed() int64 {
420 var n int64
421 b.requests.Iterate("", "", func(_ string, v any) bool {
422 n += v.(*Request).Outstanding()
423 return false
424 })
425 return n
426}
427
428// Disbursed is everything the board has ever released.
429func (b *Board) Disbursed() int64 {
430 var n int64
431 b.requests.Iterate("", "", func(_ string, v any) bool {
432 n += v.(*Request).Paid()
433 return false
434 })
435 return n
436}
437
438// idKey pads an id so avl iteration is numeric, not lexicographic: unpadded
439// keys sort "1","10","2" and every listing would lose its order past nine
440// entries. ufmt has no width flags in gno, so the padding is by hand.
441func idKey(id int) string {
442 s := strconv.Itoa(id)
443 for len(s) < 8 {
444 s = "0" + s
445 }
446 return s
447}