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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

grants.gno

13.60 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/nym-moultest123/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}