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

lifecycle.gno

10.60 Kb · 371 lines
  1package grants
  2
  3// This file holds every state transition of the board. Each one takes the
  4// acting address and the block height from the caller rather than reading
  5// them from the chain, which is what keeps the package pure and the whole
  6// lifecycle exercisable in a plain unit test.
  7
  8// NewMilestone is a small constructor so callers do not build the struct (and
  9// its Attempts slice) by hand.
 10func NewMilestone(title string, amount int64) *Milestone {
 11	return &Milestone{Title: title, Amount: amount}
 12}
 13
 14// Submit files a grant application for the applicant themselves. Anyone may
 15// apply; being a member is not required and does not help, since a member is
 16// barred from voting on their own request.
 17func (b *Board) Submit(applicant address, title, body string, ms []*Milestone, height int64) (*Request, error) {
 18	return b.SubmitFor(applicant, applicant, "", title, body, ms, height)
 19}
 20
 21// SubmitFor files a grant application whose money goes to beneficiary rather
 22// than to the applicant, with a reason for the detour.
 23//
 24// The reason is required, and it is required because it is the only thing on
 25// the page that answers the question a reader will have: why is this person
 26// asking for someone else's money. The usual honest answer is that the payee
 27// has an empty account and cannot pay the gas to ask, which is exactly the
 28// case a grant board exists to serve and exactly the case an impersonation
 29// looks like. Making the claim explicit is what lets a member check it.
 30//
 31// Passing the applicant's own address is the same as Submit and needs no
 32// reason.
 33func (b *Board) SubmitFor(applicant, beneficiary address, reason, title, body string, ms []*Milestone, height int64) (*Request, error) {
 34	if !applicant.IsValid() || !beneficiary.IsValid() {
 35		return nil, ErrBadAddress
 36	}
 37	if len(reason) > MaxReason {
 38		return nil, ErrBadReason
 39	}
 40	if beneficiary != applicant && reason == "" {
 41		return nil, ErrNeedReason
 42	}
 43	if err := checkTitle(title); err != nil {
 44		return nil, err
 45	}
 46	if len(body) > MaxBody {
 47		return nil, ErrBadBody
 48	}
 49	if len(ms) == 0 || len(ms) > MaxMilestones {
 50		return nil, ErrBadMilestones
 51	}
 52	for _, m := range ms {
 53		if m == nil || m.Amount <= 0 {
 54			return nil, ErrBadAmount
 55		}
 56		if err := checkTitle(m.Title); err != nil {
 57			return nil, err
 58		}
 59	}
 60	return b.file(&Request{
 61		Kind:        KindGrant,
 62		Applicant:   applicant,
 63		Beneficiary: beneficiary,
 64		Reason:      reason,
 65		Title:       title,
 66		Body:        body,
 67		Milestones:  ms,
 68		CreatedAt:   height,
 69	}), nil
 70}
 71
 72// SubmitMemberChange files a request to add or remove a board member. Only a
 73// member may file one: opening the board's own composition to anyone with a
 74// keypair is how a grant board gets captured.
 75func (b *Board) SubmitMemberChange(proposer, subject address, add bool, body string, height int64) (*Request, error) {
 76	if !b.IsMember(proposer) {
 77		return nil, ErrNotMember
 78	}
 79	if !subject.IsValid() {
 80		return nil, ErrBadAddress
 81	}
 82	if len(body) > MaxBody {
 83		return nil, ErrBadBody
 84	}
 85	switch {
 86	case add && b.IsMember(subject):
 87		return nil, ErrIsMember
 88	case !add && !b.IsMember(subject):
 89		return nil, ErrNotMember
 90	case !add && b.MemberCount() == 1:
 91		return nil, ErrLastMember
 92	}
 93	title := "Remove " + subject.String()
 94	if add {
 95		title = "Add " + subject.String()
 96	}
 97	return b.file(&Request{
 98		Kind:        KindMember,
 99		Applicant:   proposer,
100		Beneficiary: proposer,
101		Title:       title,
102		Body:        body,
103		Subject:     subject,
104		Add:         add,
105		CreatedAt:   height,
106	}), nil
107}
108
109func (b *Board) file(r *Request) *Request {
110	r.ID = b.nextID
111	b.nextID++
112	b.requests.Set(idKey(r.ID), r)
113	return r
114}
115
116// Withdraw lets an applicant pull their own request before it is decided.
117func (b *Board) Withdraw(caller address, id int, height int64) error {
118	r := b.Get(id)
119	if r == nil {
120		return ErrNoRequest
121	}
122	if r.Applicant != caller {
123		return ErrNotApplicant
124	}
125	if r.Status != Pending {
126		return ErrNotPending
127	}
128	r.Status, r.DecidedAt = Withdrawn, height
129	return nil
130}
131
132// Standing counts only the ballots of addresses that are members right now.
133// Decisions use this, not Request.Tally: a ballot is a permanent record of
134// what someone said, but it stops carrying weight the moment they leave the
135// board. The two numbers differ exactly when a voter has since been removed,
136// and a good renderer shows both.
137func (b *Board) Standing(r *Request) (yes, no int) { return b.standing(r.Votes) }
138
139// Decides previews what a ballot would do without casting it, so a caller can
140// check a precondition it cannot roll back: a realm, for instance, refusing
141// to let a grant carry that its treasury cannot cover. It reports false for a
142// ballot that would be refused anyway.
143func (b *Board) Decides(id int, voter address, approve bool) (bool, Status) {
144	r := b.Get(id)
145	if r == nil || r.Status != Pending {
146		return false, Pending
147	}
148	if !b.IsMember(voter) || r.Excludes(voter) || r.BallotOf(voter) != nil {
149		return false, Pending
150	}
151	yes, no := b.Standing(r)
152	if approve {
153		yes++
154	} else {
155		no++
156	}
157	switch m := b.Majority(r); {
158	case yes >= m:
159		return true, Approved
160	case no >= m:
161		return true, Rejected
162	}
163	return false, Pending
164}
165
166// ReviewDecides is Decides for a proof review: it reports whether this
167// verdict would close the attempt under review, and how. A realm uses it to
168// check, before recording anything, that it can actually pay a tranche it is
169// about to release.
170func (b *Board) ReviewDecides(id, idx int, voter address, accept bool) (bool, Outcome) {
171	r := b.Get(id)
172	if r == nil || r.Kind != KindGrant || r.Status != Approved {
173		return false, UnderReview
174	}
175	if idx < 0 || idx >= len(r.Milestones) {
176		return false, UnderReview
177	}
178	a := r.Milestones[idx].Current()
179	if a == nil {
180		return false, UnderReview
181	}
182	if !b.IsMember(voter) || r.Excludes(voter) || find(a.Reviews, voter) != nil {
183		return false, UnderReview
184	}
185	yes, no := b.standing(a.Reviews)
186	if accept {
187		yes++
188	} else {
189		no++
190	}
191	switch m := b.Majority(r); {
192	case yes >= m:
193		return true, Accepted
194	case no >= m:
195		return true, Refused
196	}
197	return false, UnderReview
198}
199
200// standing counts ballots from current members only. See Standing.
201func (b *Board) standing(bs []Ballot) (yes, no int) {
202	for _, v := range bs {
203		if !b.IsMember(v.Voter) {
204			continue
205		}
206		if v.Approve {
207			yes++
208		} else {
209			no++
210		}
211	}
212	return yes, no
213}
214
215// Vote casts one member's ballot on a request and returns the status it left
216// the request in. A member votes once; there is no changing your mind, which
217// is the price of every ballot being a permanent public statement.
218func (b *Board) Vote(voter address, id int, approve bool, reason string, height int64) (Status, error) {
219	r := b.Get(id)
220	if r == nil {
221		return Pending, ErrNoRequest
222	}
223	if !b.IsMember(voter) {
224		return r.Status, ErrNotMember
225	}
226	if r.Status != Pending {
227		return r.Status, ErrNotPending
228	}
229	if r.Excludes(voter) {
230		return r.Status, ErrConflict
231	}
232	if r.BallotOf(voter) != nil {
233		return r.Status, ErrAlreadyVoted
234	}
235	if len(reason) > MaxReason {
236		return r.Status, ErrBadReason
237	}
238
239	r.Votes = append(r.Votes, Ballot{Voter: voter, Approve: approve, Reason: reason, Height: height})
240
241	yes, no := b.Standing(r)
242	switch m := b.Majority(r); {
243	case yes >= m:
244		r.Status, r.DecidedAt = Approved, height
245		if r.Kind == KindMember {
246			b.applyMemberChange(r, height)
247		}
248	case no >= m:
249		r.Status, r.DecidedAt = Rejected, height
250	}
251	return r.Status, nil
252}
253
254// applyMemberChange is the whole execution engine for KindMember: a carried
255// membership request takes effect at once, with nothing to claim afterwards,
256// so it goes straight from Approved to Completed.
257func (b *Board) applyMemberChange(r *Request, height int64) {
258	if r.Add {
259		b.members.Set(r.Subject.String(), height)
260	} else {
261		b.members.Remove(r.Subject.String())
262	}
263	r.Status = Completed
264}
265
266// SubmitProof offers evidence for the next unreleased milestone of an
267// approved grant. Milestones are earned in order, and only one proof is under
268// review at a time.
269//
270// Either the applicant or the beneficiary may submit. On a request filed for
271// an empty account, the payee cannot transact until the first tranche lands,
272// so restricting this to the payee would strand the grant it was filed to
273// unstick; restricting it to the filer would leave the payee unable to show
274// their own work once they can.
275func (b *Board) SubmitProof(caller address, id, idx int, p Proof) error {
276	r := b.Get(id)
277	if r == nil {
278		return ErrNoRequest
279	}
280	if r.Kind != KindGrant || r.Status != Approved {
281		return ErrNotApproved
282	}
283	if r.Applicant != caller && r.Payee() != caller {
284		return ErrNotApplicant
285	}
286	if idx < 0 || idx >= len(r.Milestones) {
287		return ErrNoMilestone
288	}
289	if idx != r.Next() {
290		return ErrOutOfOrder
291	}
292	if err := checkProof(p); err != nil {
293		return err
294	}
295	m := r.Milestones[idx]
296	if m.Current() != nil {
297		return ErrProofPending
298	}
299	m.Attempts = append(m.Attempts, &Attempt{Proof: p, Outcome: UnderReview})
300	return nil
301}
302
303// Review casts a member's verdict on the proof currently under review and
304// returns the outcome that verdict left the attempt in. Accepting it releases
305// the tranche, which in this package means marking it Released and nothing
306// more; moving the coins is the caller's job, and Request.Paid tells it how
307// much it now owes. Refusing closes the attempt and lets the applicant try
308// again with better evidence.
309func (b *Board) Review(voter address, id, idx int, accept bool, reason string, height int64) (Outcome, error) {
310	r := b.Get(id)
311	if r == nil {
312		return UnderReview, ErrNoRequest
313	}
314	if !b.IsMember(voter) {
315		return UnderReview, ErrNotMember
316	}
317	if r.Kind != KindGrant || r.Status != Approved {
318		return UnderReview, ErrNotApproved
319	}
320	if idx < 0 || idx >= len(r.Milestones) {
321		return UnderReview, ErrNoMilestone
322	}
323	if r.Excludes(voter) {
324		return UnderReview, ErrConflict
325	}
326	if len(reason) > MaxReason {
327		return UnderReview, ErrBadReason
328	}
329	m := r.Milestones[idx]
330	a := m.Current()
331	if a == nil {
332		return UnderReview, ErrNoProof
333	}
334	if find(a.Reviews, voter) != nil {
335		return UnderReview, ErrAlreadyVoted
336	}
337
338	a.Reviews = append(a.Reviews, Ballot{Voter: voter, Approve: accept, Reason: reason, Height: height})
339
340	yes, no := b.standing(a.Reviews)
341	switch mj := b.Majority(r); {
342	case yes >= mj:
343		a.Outcome, a.ClosedAt = Accepted, height
344		m.Released, m.ReleasedAt = true, height
345		if r.Next() == -1 {
346			r.Status = Completed
347		}
348	case no >= mj:
349		a.Outcome, a.ClosedAt = Refused, height
350	}
351	return a.Outcome, nil
352}
353
354func checkTitle(s string) error {
355	if len(s) == 0 || len(s) > MaxTitle {
356		return ErrBadTitle
357	}
358	return nil
359}
360
361func checkProof(p Proof) error {
362	switch p.Kind {
363	case "url", "hash", "text":
364	default:
365		return ErrBadProof
366	}
367	if len(p.Ref) == 0 || len(p.Ref) > MaxRef || len(p.Note) > MaxNote {
368		return ErrBadProof
369	}
370	return nil
371}