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}