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.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}