Kind Pure package
Name v0
Namespace moul / grants
Exported functions n/a — not supported for pure packages by the node (vm/qfuncs)
Module gno.land/p/moul/grants/v0
gno 0.9 lifecycle.gno gno
⧉
1 package 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 not supported for pure packages by the node (vm/qfuncs)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
// NewMilestone is a small constructor so callers do not build the struct (and
9 // its Attempts slice) by hand.
10 func 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.
17 func (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.
33 func (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.
75 func (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
109 func (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.
117 func (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.
137 func (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.
143 func (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.
170 func (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.
201 func (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.
218 func (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.
257 func (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.
275 func (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.
309 func (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
354 func checkTitle(s string ) error {
355 if len(s) == 0 || len(s) > MaxTitle {
356 return ErrBadTitle
357 }
358 return nil
359 }
360
361 func 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 }
372