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

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}