campaign.gno
13.16 Kb · 494 lines
1// Package campaign is the state machine behind Guildhall campaigns: bounties
2// with a fixed reward per claim and a fixed number of slots, for small tasks
3// many people can do (write a post, translate a page, file a bug report).
4//
5// Like gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounty,
6// it holds no funds and moves no coins. Transitions validate who is acting,
7// mutate the campaign, and return the transfers the escrow realm must make.
8//
9// The poster escrows Reward × Slots. Anyone except the poster and the
10// reviewers can claim with a link to proof of the work; a pending claim
11// reserves a slot. Any one reviewer approves (pays the reward) or rejects
12// (frees the slot). Each address holds at most one pending or approved claim.
13//
14// Open ──Close (poster, or anyone after the deadline)──▶ Closed (free slots refunded)
15// Open ──(every slot paid)──▶ Closed
16//
17// Claim ──Approve──▶ Approved (reward paid)
18// └────Reject───▶ Rejected (slot freed; refunded if the campaign is closed)
19//
20// Pending claims can still be reviewed after a campaign closes.
21package campaign
22
23import (
24 "errors"
25 "strconv"
26 "strings"
27 "time"
28
29 "gno.land/p/nt/avl/v0"
30 "gno.land/p/nt/ufmt/v0"
31)
32
33// Limits keep campaigns renderable and every transition bounded in gas.
34const (
35 MaxTitleLen = 120
36 MaxDescriptionLen = 8000
37 MaxLinkLen = 300
38 MaxNoteLen = 1000
39 MaxSlots = 1000
40 MaxReviewers = 9
41 MaxTags = 5
42 MaxTagLen = 24
43)
44
45var (
46 ErrUnauthorized = errors.New("campaign: unauthorized")
47 ErrClosed = errors.New("campaign: closed to new claims")
48 ErrBadClaim = errors.New("campaign: unknown claim")
49)
50
51// Status is the lifecycle state of a campaign.
52type Status int
53
54const (
55 StatusOpen Status = iota
56 StatusClosed
57)
58
59func (s Status) String() string {
60 switch s {
61 case StatusOpen:
62 return "open"
63 case StatusClosed:
64 return "closed"
65 }
66 return "unknown"
67}
68
69// ParseStatus is the inverse of Status.String.
70func ParseStatus(s string) (Status, bool) {
71 for _, st := range []Status{StatusOpen, StatusClosed} {
72 if st.String() == s {
73 return st, true
74 }
75 }
76 return 0, false
77}
78
79// ClaimStatus is the state of a single claim.
80type ClaimStatus int
81
82const (
83 ClaimPending ClaimStatus = iota
84 ClaimApproved
85 ClaimRejected
86)
87
88func (s ClaimStatus) String() string {
89 switch s {
90 case ClaimPending:
91 return "pending"
92 case ClaimApproved:
93 return "approved"
94 case ClaimRejected:
95 return "rejected"
96 }
97 return "unknown"
98}
99
100// ParseClaimStatus is the inverse of ClaimStatus.String.
101func ParseClaimStatus(s string) (ClaimStatus, bool) {
102 for _, st := range []ClaimStatus{ClaimPending, ClaimApproved, ClaimRejected} {
103 if st.String() == s {
104 return st, true
105 }
106 }
107 return 0, false
108}
109
110// Claim is one person's request to be paid for doing the task once.
111type Claim struct {
112 Number int // 1-based, in order of submission
113 Claimant address
114 Proof string
115 Status ClaimStatus
116 At time.Time
117 ReviewedBy address
118 ReviewedAt time.Time
119 Reason string // why it was rejected
120}
121
122// Campaign is a pool of identical, individually paid tasks.
123type Campaign struct {
124 ID uint64
125 Poster address
126 Title string
127 Description string
128 Link string
129 Tags []string
130 Denom string
131 IsGRC20 bool
132 Reward int64
133 Slots int
134 Reviewers []address
135 Deadline time.Time // zero: no deadline
136 Status Status
137 CreatedAt time.Time
138 ClosedAt time.Time
139
140 Paid int // approved claims
141 Pending int // claims awaiting review
142 Rejected int
143 Refunded int // slots returned to the poster
144
145 claims avl.Tree // padded claim number -> *Claim
146 byClaimant avl.Tree // address -> latest claim number (int)
147}
148
149// Params are the inputs to New.
150type Params struct {
151 ID uint64
152 Poster address
153 Title string
154 Description string
155 Link string
156 Tags []string
157 Denom string
158 IsGRC20 bool
159 Reward int64
160 Slots int
161 Reviewers []address
162 Deadline time.Time
163 Now time.Time
164}
165
166// PayoutKind says why escrowed funds move.
167type PayoutKind string
168
169const (
170 PayoutWork PayoutKind = "work"
171 PayoutRefund PayoutKind = "refund"
172)
173
174// Payout is a transfer the escrow realm must execute. Claim is the claim
175// number for work payouts and 0 for refunds.
176type Payout struct {
177 Kind PayoutKind
178 To address
179 Amount int64
180 Claim int
181}
182
183// New validates p and returns a funded, open campaign. When no reviewers are
184// given, the poster reviews alone.
185func New(p Params) (*Campaign, error) {
186 if !p.Poster.IsValid() {
187 return nil, errors.New("campaign: invalid poster address")
188 }
189 title := strings.TrimSpace(p.Title)
190 if title == "" || len(title) > MaxTitleLen {
191 return nil, errorf("campaign: title must be 1..%d bytes", MaxTitleLen)
192 }
193 if len(p.Description) > MaxDescriptionLen {
194 return nil, errorf("campaign: description exceeds %d bytes", MaxDescriptionLen)
195 }
196 link := strings.TrimSpace(p.Link)
197 if len(link) > MaxLinkLen {
198 return nil, errorf("campaign: link exceeds %d bytes", MaxLinkLen)
199 }
200 tags, err := normalizeTags(p.Tags)
201 if err != nil {
202 return nil, err
203 }
204 if p.Denom == "" {
205 return nil, errors.New("campaign: missing denomination")
206 }
207 if p.Reward <= 0 {
208 return nil, errors.New("campaign: reward must be positive")
209 }
210 if p.Slots < 1 || p.Slots > MaxSlots {
211 return nil, errorf("campaign: slots must be within 1..%d", MaxSlots)
212 }
213 if p.Reward > (1<<63-1)/int64(p.Slots) {
214 return nil, errors.New("campaign: total overflows")
215 }
216
217 reviewers := p.Reviewers
218 if len(reviewers) == 0 {
219 reviewers = []address{p.Poster}
220 }
221 if len(reviewers) > MaxReviewers {
222 return nil, errorf("campaign: at most %d reviewers", MaxReviewers)
223 }
224 seen := map[address]bool{}
225 for _, r := range reviewers {
226 if !r.IsValid() {
227 return nil, errors.New("campaign: invalid reviewer address " + r.String())
228 }
229 if seen[r] {
230 return nil, errors.New("campaign: duplicate reviewer " + r.String())
231 }
232 seen[r] = true
233 }
234 if !p.Deadline.IsZero() && !p.Deadline.After(p.Now) {
235 return nil, errors.New("campaign: deadline must be in the future")
236 }
237
238 return &Campaign{
239 ID: p.ID,
240 Poster: p.Poster,
241 Title: title,
242 Description: p.Description,
243 Link: link,
244 Tags: tags,
245 Denom: p.Denom,
246 IsGRC20: p.IsGRC20,
247 Reward: p.Reward,
248 Slots: p.Slots,
249 Reviewers: append([]address{}, reviewers...),
250 Deadline: p.Deadline,
251 Status: StatusOpen,
252 CreatedAt: p.Now,
253 }, nil
254}
255
256// Claim records that claimant did the task, with a link to proof, and
257// reserves a slot. It returns the claim number.
258func (c *Campaign) Claim(claimant address, proof string, now time.Time) (int, error) {
259 if c.Status != StatusOpen {
260 return 0, ErrClosed
261 }
262 if c.Expired(now) {
263 return 0, errors.New("campaign: the deadline has passed")
264 }
265 if !claimant.IsValid() {
266 return 0, errors.New("campaign: invalid claimant address")
267 }
268 if claimant == c.Poster || c.IsReviewer(claimant) {
269 return 0, errors.New("campaign: the poster and reviewers cannot claim")
270 }
271 proof = strings.TrimSpace(proof)
272 if proof == "" || len(proof) > MaxLinkLen {
273 return 0, errorf("campaign: proof link must be 1..%d bytes", MaxLinkLen)
274 }
275 if prev := c.ClaimOf(claimant); prev != nil && prev.Status != ClaimRejected {
276 article := "a "
277 if prev.Status == ClaimApproved {
278 article = "an "
279 }
280 return 0, errors.New("campaign: you already have " + article + prev.Status.String() + " claim (#" + strconv.Itoa(prev.Number) + ")")
281 }
282 if c.FreeSlots() == 0 {
283 return 0, errors.New("campaign: every slot is taken or awaiting review")
284 }
285 n := c.claims.Size() + 1
286 c.claims.Set(claimKey(n), &Claim{Number: n, Claimant: claimant, Proof: proof, Status: ClaimPending, At: now})
287 c.byClaimant.Set(claimant.String(), n)
288 c.Pending++
289 return n, nil
290}
291
292// Approve pays a pending claim. Any one reviewer may approve. When the last
293// slot is paid, the campaign closes.
294func (c *Campaign) Approve(caller address, number int, now time.Time) ([]Payout, error) {
295 cl, err := c.pendingClaim(caller, number)
296 if err != nil {
297 return nil, err
298 }
299 cl.Status = ClaimApproved
300 cl.ReviewedBy = caller
301 cl.ReviewedAt = now
302 c.Pending--
303 c.Paid++
304 if c.Status == StatusOpen && c.Paid == c.Slots {
305 c.Status = StatusClosed
306 c.ClosedAt = now
307 }
308 return []Payout{{Kind: PayoutWork, To: cl.Claimant, Amount: c.Reward, Claim: number}}, nil
309}
310
311// Reject turns down a pending claim and frees its slot. If the campaign has
312// closed, the slot's reward goes back to the poster. The claimant may claim
313// again while the campaign is open.
314func (c *Campaign) Reject(caller address, number int, reason string, now time.Time) ([]Payout, error) {
315 cl, err := c.pendingClaim(caller, number)
316 if err != nil {
317 return nil, err
318 }
319 reason = strings.TrimSpace(reason)
320 if len(reason) > MaxNoteLen {
321 return nil, errorf("campaign: reason exceeds %d bytes", MaxNoteLen)
322 }
323 cl.Status = ClaimRejected
324 cl.ReviewedBy = caller
325 cl.ReviewedAt = now
326 cl.Reason = reason
327 c.Pending--
328 c.Rejected++
329 if c.Status == StatusClosed {
330 c.Refunded++
331 return []Payout{{Kind: PayoutRefund, To: c.Poster, Amount: c.Reward}}, nil
332 }
333 return nil, nil
334}
335
336// Close stops new claims and refunds every slot that is neither paid nor
337// awaiting review. The poster can close at any time; anyone can close once
338// the deadline has passed.
339func (c *Campaign) Close(caller address, now time.Time) ([]Payout, error) {
340 if c.Status != StatusOpen {
341 return nil, errors.New("campaign: already closed")
342 }
343 if caller != c.Poster && !c.Expired(now) {
344 return nil, ErrUnauthorized
345 }
346 free := c.FreeSlots()
347 c.Status = StatusClosed
348 c.ClosedAt = now
349 if free == 0 {
350 return nil, nil
351 }
352 c.Refunded += free
353 return []Payout{{Kind: PayoutRefund, To: c.Poster, Amount: c.Reward * int64(free)}}, nil
354}
355
356func (c *Campaign) pendingClaim(caller address, number int) (*Claim, error) {
357 if !c.IsReviewer(caller) {
358 return nil, ErrUnauthorized
359 }
360 cl := c.GetClaim(number)
361 if cl == nil {
362 return nil, ErrBadClaim
363 }
364 if cl.Status != ClaimPending {
365 return nil, errors.New("campaign: claim #" + strconv.Itoa(number) + " is already " + cl.Status.String())
366 }
367 return cl, nil
368}
369
370// Total is the amount escrowed when the campaign was posted.
371func (c *Campaign) Total() int64 { return c.Reward * int64(c.Slots) }
372
373// Escrowed is the amount still held for this campaign.
374func (c *Campaign) Escrowed() int64 {
375 return c.Reward * int64(c.Slots-c.Paid-c.Refunded)
376}
377
378// PaidOut is the amount paid to claimants so far.
379func (c *Campaign) PaidOut() int64 { return c.Reward * int64(c.Paid) }
380
381// FreeSlots is how many more claims an open campaign can take.
382func (c *Campaign) FreeSlots() int {
383 if c.Status != StatusOpen {
384 return 0
385 }
386 return c.Slots - c.Paid - c.Pending - c.Refunded
387}
388
389// Expired reports whether the campaign has a deadline that has passed.
390func (c *Campaign) Expired(now time.Time) bool {
391 return !c.Deadline.IsZero() && !now.Before(c.Deadline)
392}
393
394// IsReviewer reports whether addr may approve and reject claims.
395func (c *Campaign) IsReviewer(addr address) bool {
396 for _, r := range c.Reviewers {
397 if r == addr {
398 return true
399 }
400 }
401 return false
402}
403
404// ClaimCount is the number of claims ever submitted.
405func (c *Campaign) ClaimCount() int { return c.claims.Size() }
406
407// GetClaim returns claim number (1-based), or nil.
408func (c *Campaign) GetClaim(number int) *Claim {
409 if number < 1 {
410 return nil
411 }
412 v := c.claims.Get(claimKey(number))
413 if v == nil {
414 return nil
415 }
416 return v.(*Claim)
417}
418
419// ClaimOf returns addr's most recent claim, or nil.
420func (c *Campaign) ClaimOf(addr address) *Claim {
421 v := c.byClaimant.Get(addr.String())
422 if v == nil {
423 return nil
424 }
425 return c.GetClaim(v.(int))
426}
427
428// Claims returns up to limit claims, newest first, after skipping offset
429// claims. With a status filter, only matching claims are counted.
430func (c *Campaign) Claims(status *ClaimStatus, offset, limit int) (out []*Claim, more bool) {
431 c.claims.ReverseIterate("", "", func(_ string, v any) bool {
432 cl := v.(*Claim)
433 if status != nil && cl.Status != *status {
434 return false
435 }
436 if offset > 0 {
437 offset--
438 return false
439 }
440 if len(out) == limit {
441 more = true
442 return true
443 }
444 out = append(out, cl)
445 return false
446 })
447 return out, more
448}
449
450// ParseAddresses splits a comma or whitespace separated address list.
451func ParseAddresses(s string) []address {
452 var out []address
453 for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
454 out = append(out, address(f))
455 }
456 return out
457}
458
459func normalizeTags(in []string) ([]string, error) {
460 var out []string
461 seen := map[string]bool{}
462 for _, t := range in {
463 t = strings.ToLower(strings.TrimSpace(t))
464 if t == "" || seen[t] {
465 continue
466 }
467 if len(t) > MaxTagLen {
468 return nil, errorf("campaign: tag %q exceeds %d bytes", t, MaxTagLen)
469 }
470 for _, r := range t {
471 if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
472 return nil, errors.New("campaign: tags may only contain a-z, 0-9 and '-'")
473 }
474 }
475 seen[t] = true
476 out = append(out, t)
477 }
478 if len(out) > MaxTags {
479 return nil, errorf("campaign: at most %d tags", MaxTags)
480 }
481 return out, nil
482}
483
484func claimKey(n int) string {
485 s := strconv.Itoa(n)
486 for len(s) < 6 {
487 s = "0" + s
488 }
489 return s
490}
491
492func errorf(format string, args ...any) error {
493 return errors.New(ufmt.Sprintf(format, args...))
494}