bounty.gno
19.51 Kb · 760 lines
1// Package bounty is the state machine behind Guildhall bounties.
2//
3// It holds no funds and moves no coins. Every transition validates who is
4// acting and what state the bounty is in, mutates the bounty, and returns the
5// transfers the escrow realm must execute as a list of Payouts. Keeping the
6// rules here, free of chain effects, lets them be tested exhaustively and
7// reused by any realm that wants to escrow work.
8//
9// Lifecycle:
10//
11// Open ──Assign──▶ Active ──(every milestone settled)──▶ Completed
12// ▲ │
13// └──Unassign/Resign┘
14// Open ──Cancel──▶ Cancelled (unpaid milestones refunded)
15//
16// Milestones move independently once a bounty is Active:
17//
18// Pending ──Submit──▶ Submitted ──Approve×quorum──▶ Paid
19// ▲ │ │
20// └──RequestChanges──┘ └──OpenDispute──▶ Disputed ──Resolve──▶ Paid | Split | Refunded
21//
22// A milestone with requested changes can be resubmitted or disputed.
23package bounty
24
25import (
26 "errors"
27 "strings"
28 "time"
29
30 "gno.land/p/nt/ufmt/v0"
31)
32
33// Limits keep bounties renderable and every transition bounded in gas.
34const (
35 MaxTitleLen = 120
36 MaxDescriptionLen = 8000
37 MaxLinkLen = 300
38 MaxNoteLen = 1000
39 MaxMilestones = 20
40 MaxReviewers = 9
41 MaxApplicants = 50
42 MaxTags = 5
43 MaxTagLen = 24
44 MaxBps = 10000
45 DefaultWorkWindow = 30 * 24 * time.Hour
46)
47
48var (
49 ErrUnauthorized = errors.New("bounty: unauthorized")
50 ErrBadState = errors.New("bounty: invalid state for this action")
51 ErrBadMilestone = errors.New("bounty: unknown milestone")
52)
53
54// Status is the lifecycle state of a bounty.
55type Status int
56
57const (
58 StatusOpen Status = iota
59 StatusActive
60 StatusCompleted
61 StatusCancelled
62)
63
64func (s Status) String() string {
65 switch s {
66 case StatusOpen:
67 return "open"
68 case StatusActive:
69 return "active"
70 case StatusCompleted:
71 return "completed"
72 case StatusCancelled:
73 return "cancelled"
74 }
75 return "unknown"
76}
77
78// ParseStatus is the inverse of Status.String.
79func ParseStatus(s string) (Status, bool) {
80 for _, st := range []Status{StatusOpen, StatusActive, StatusCompleted, StatusCancelled} {
81 if st.String() == s {
82 return st, true
83 }
84 }
85 return 0, false
86}
87
88// MilestoneStatus is the state of a single milestone.
89type MilestoneStatus int
90
91const (
92 MilestonePending MilestoneStatus = iota
93 MilestoneSubmitted
94 MilestoneChangesRequested
95 MilestoneDisputed
96 MilestonePaid
97 MilestoneSplit
98 MilestoneRefunded
99)
100
101func (s MilestoneStatus) String() string {
102 switch s {
103 case MilestonePending:
104 return "pending"
105 case MilestoneSubmitted:
106 return "submitted"
107 case MilestoneChangesRequested:
108 return "changes requested"
109 case MilestoneDisputed:
110 return "disputed"
111 case MilestonePaid:
112 return "paid"
113 case MilestoneSplit:
114 return "split"
115 case MilestoneRefunded:
116 return "refunded"
117 }
118 return "unknown"
119}
120
121// Settled reports whether the milestone's escrow has been fully released.
122func (s MilestoneStatus) Settled() bool {
123 return s == MilestonePaid || s == MilestoneSplit || s == MilestoneRefunded
124}
125
126// Dispute records a contested milestone and how it was settled.
127type Dispute struct {
128 OpenedBy address
129 Reason string
130 OpenedAt time.Time
131 Resolved bool
132 ResolvedAt time.Time
133 // WorkerBps is the share of the milestone the arbiter awarded the worker.
134 WorkerBps int64
135 Ruling string
136}
137
138// Milestone is one independently payable slice of a bounty.
139type Milestone struct {
140 Title string
141 Amount int64
142 Status MilestoneStatus
143 Submission string
144 SubmittedAt time.Time
145 Approvals []address
146 Feedback string
147 Dispute *Dispute
148 // Worker is the assignee at the time the milestone was submitted. It
149 // stays fixed through review and dispute so a later reassignment cannot
150 // redirect payment for work someone else delivered.
151 Worker address
152 PaidOut int64
153 Refunded int64
154 SettleAt time.Time
155}
156
157// Application is a contributor's request to be assigned.
158type Application struct {
159 Applicant address
160 Pitch string
161 At time.Time
162}
163
164// Bounty is a funded task split into milestones.
165type Bounty struct {
166 ID uint64
167 Poster address
168 Title string
169 Description string
170 Link string
171 Tags []string
172 // Denom identifies what the escrow holds: a native coin denom, or a
173 // grc20reg key when IsGRC20 is set.
174 Denom string
175 IsGRC20 bool
176
177 Reviewers []address
178 Quorum int
179 Arbiter address
180
181 Assignee address
182 Applications []Application
183 Milestones []*Milestone
184 Status Status
185 WorkWindow time.Duration
186
187 CreatedAt time.Time
188 AssignedAt time.Time
189 ClosedAt time.Time
190}
191
192// MilestoneSpec describes a milestone when posting a bounty.
193type MilestoneSpec struct {
194 Title string
195 Amount int64
196}
197
198// Params are the inputs to New.
199type Params struct {
200 ID uint64
201 Poster address
202 Title string
203 Description string
204 Link string
205 Tags []string
206 Denom string
207 IsGRC20 bool
208 Reviewers []address
209 Quorum int
210 Arbiter address
211 Milestones []MilestoneSpec
212 WorkWindow time.Duration
213 Now time.Time
214}
215
216// PayoutKind says why escrowed funds move.
217type PayoutKind string
218
219const (
220 PayoutWork PayoutKind = "work"
221 PayoutRefund PayoutKind = "refund"
222)
223
224// Payout is a transfer the escrow realm must execute.
225type Payout struct {
226 Kind PayoutKind
227 To address
228 Amount int64
229 Milestone int
230}
231
232// New validates p and returns a funded, open bounty.
233//
234// When no reviewers are given, the poster reviews alone. When no arbiter is
235// given, the caller is expected to fill in a default (the escrow realm's
236// council) before calling New.
237func New(p Params) (*Bounty, error) {
238 if !p.Poster.IsValid() {
239 return nil, errors.New("bounty: invalid poster address")
240 }
241 title := strings.TrimSpace(p.Title)
242 if title == "" || len(title) > MaxTitleLen {
243 return nil, errorf("bounty: title must be 1..%d bytes", MaxTitleLen)
244 }
245 if len(p.Description) > MaxDescriptionLen {
246 return nil, errorf("bounty: description exceeds %d bytes", MaxDescriptionLen)
247 }
248 if len(p.Link) > MaxLinkLen {
249 return nil, errorf("bounty: link exceeds %d bytes", MaxLinkLen)
250 }
251 tags, err := normalizeTags(p.Tags)
252 if err != nil {
253 return nil, err
254 }
255 if p.Denom == "" {
256 return nil, errors.New("bounty: missing denomination")
257 }
258
259 reviewers := p.Reviewers
260 if len(reviewers) == 0 {
261 reviewers = []address{p.Poster}
262 }
263 if len(reviewers) > MaxReviewers {
264 return nil, errorf("bounty: at most %d reviewers", MaxReviewers)
265 }
266 seen := map[address]bool{}
267 for _, r := range reviewers {
268 if !r.IsValid() {
269 return nil, errors.New("bounty: invalid reviewer address " + r.String())
270 }
271 if seen[r] {
272 return nil, errors.New("bounty: duplicate reviewer " + r.String())
273 }
274 seen[r] = true
275 }
276 quorum := p.Quorum
277 if quorum == 0 {
278 quorum = 1
279 }
280 if quorum < 1 || quorum > len(reviewers) {
281 return nil, errorf("bounty: quorum must be within 1..%d", len(reviewers))
282 }
283 if !p.Arbiter.IsValid() {
284 return nil, errors.New("bounty: invalid arbiter address")
285 }
286
287 if len(p.Milestones) == 0 || len(p.Milestones) > MaxMilestones {
288 return nil, errorf("bounty: need 1..%d milestones", MaxMilestones)
289 }
290 ms := make([]*Milestone, 0, len(p.Milestones))
291 total := int64(0)
292 for i, spec := range p.Milestones {
293 mt := strings.TrimSpace(spec.Title)
294 if mt == "" || len(mt) > MaxTitleLen {
295 return nil, errorf("bounty: milestone %d title must be 1..%d bytes", i+1, MaxTitleLen)
296 }
297 if spec.Amount <= 0 {
298 return nil, errorf("bounty: milestone %d amount must be positive", i+1)
299 }
300 if total+spec.Amount < total {
301 return nil, errors.New("bounty: total overflows")
302 }
303 total += spec.Amount
304 ms = append(ms, &Milestone{Title: mt, Amount: spec.Amount})
305 }
306
307 window := p.WorkWindow
308 if window == 0 {
309 window = DefaultWorkWindow
310 }
311 if window < 0 {
312 return nil, errors.New("bounty: work window must be positive")
313 }
314
315 return &Bounty{
316 ID: p.ID,
317 Poster: p.Poster,
318 Title: title,
319 Description: p.Description,
320 Link: p.Link,
321 Tags: tags,
322 Denom: p.Denom,
323 IsGRC20: p.IsGRC20,
324 Reviewers: reviewers,
325 Quorum: quorum,
326 Arbiter: p.Arbiter,
327 Milestones: ms,
328 Status: StatusOpen,
329 WorkWindow: window,
330 CreatedAt: p.Now,
331 }, nil
332}
333
334// ParseMilestones parses "Title=amount;Title=amount". An empty string yields a
335// single "Complete the bounty" milestone worth total (which must be positive).
336func ParseMilestones(s string, total int64) ([]MilestoneSpec, error) {
337 s = strings.TrimSpace(s)
338 if s == "" {
339 if total <= 0 {
340 return nil, errors.New("bounty: no milestones and no amount")
341 }
342 return []MilestoneSpec{{Title: "Complete the bounty", Amount: total}}, nil
343 }
344 var specs []MilestoneSpec
345 for _, part := range strings.Split(s, ";") {
346 part = strings.TrimSpace(part)
347 if part == "" {
348 continue
349 }
350 eq := strings.LastIndex(part, "=")
351 if eq < 0 {
352 return nil, errors.New("bounty: milestone must be Title=amount: " + part)
353 }
354 amt, err := parseAmount(strings.TrimSpace(part[eq+1:]))
355 if err != nil {
356 return nil, err
357 }
358 specs = append(specs, MilestoneSpec{Title: strings.TrimSpace(part[:eq]), Amount: amt})
359 }
360 if len(specs) == 0 {
361 return nil, errors.New("bounty: no milestones")
362 }
363 return specs, nil
364}
365
366// ParseAddresses parses a comma- or space-separated list of addresses.
367func ParseAddresses(s string) []address {
368 var out []address
369 for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
370 out = append(out, address(f))
371 }
372 return out
373}
374
375// Total is the sum of all milestone amounts.
376func (b *Bounty) Total() int64 {
377 t := int64(0)
378 for _, m := range b.Milestones {
379 t += m.Amount
380 }
381 return t
382}
383
384// Escrowed is the amount still held for this bounty.
385func (b *Bounty) Escrowed() int64 {
386 t := int64(0)
387 for _, m := range b.Milestones {
388 if !m.Status.Settled() {
389 t += m.Amount
390 }
391 }
392 return t
393}
394
395// PaidOut is the amount released to workers.
396func (b *Bounty) PaidOut() int64 {
397 t := int64(0)
398 for _, m := range b.Milestones {
399 t += m.PaidOut
400 }
401 return t
402}
403
404// IsReviewer reports whether addr is one of the bounty's reviewers.
405func (b *Bounty) IsReviewer(addr address) bool {
406 for _, r := range b.Reviewers {
407 if r == addr {
408 return true
409 }
410 }
411 return false
412}
413
414// HasApplied reports whether addr has an application on file.
415func (b *Bounty) HasApplied(addr address) bool {
416 for _, a := range b.Applications {
417 if a.Applicant == addr {
418 return true
419 }
420 }
421 return false
422}
423
424// Milestone returns the milestone at idx.
425func (b *Bounty) Milestone(idx int) (*Milestone, error) {
426 if idx < 0 || idx >= len(b.Milestones) {
427 return nil, ErrBadMilestone
428 }
429 return b.Milestones[idx], nil
430}
431
432// Apply files an application from a prospective contributor.
433func (b *Bounty) Apply(who address, pitch string, now time.Time) error {
434 if b.Status != StatusOpen {
435 return ErrBadState
436 }
437 if who == b.Poster || b.IsReviewer(who) {
438 return errors.New("bounty: posters and reviewers cannot apply")
439 }
440 if b.HasApplied(who) {
441 return errors.New("bounty: already applied")
442 }
443 if len(b.Applications) >= MaxApplicants {
444 return errors.New("bounty: application list is full")
445 }
446 if len(pitch) > MaxNoteLen {
447 return errorf("bounty: pitch exceeds %d bytes", MaxNoteLen)
448 }
449 b.Applications = append(b.Applications, Application{Applicant: who, Pitch: pitch, At: now})
450 return nil
451}
452
453// Assign hands the bounty to a contributor. Only the poster may assign, and
454// the assignee cannot be the poster or a reviewer.
455func (b *Bounty) Assign(caller, who address, now time.Time) error {
456 if caller != b.Poster {
457 return ErrUnauthorized
458 }
459 if b.Status != StatusOpen {
460 return ErrBadState
461 }
462 if !who.IsValid() {
463 return errors.New("bounty: invalid assignee address")
464 }
465 if who == b.Poster || who == b.Arbiter || b.IsReviewer(who) {
466 return errors.New("bounty: posters, reviewers and the arbiter cannot be assigned")
467 }
468 b.Assignee = who
469 b.AssignedAt = now
470 b.Status = StatusActive
471 return nil
472}
473
474// Unassign returns an active bounty to the open pool.
475//
476// The assignee may step back at any time. The poster may reclaim the bounty
477// once the work window has elapsed since assignment. Either way, no milestone
478// may be awaiting review or arbitration: those are settled first.
479func (b *Bounty) Unassign(caller address, now time.Time) error {
480 if b.Status != StatusActive {
481 return ErrBadState
482 }
483 switch caller {
484 case b.Assignee:
485 case b.Poster:
486 if now.Before(b.AssignedAt.Add(b.WorkWindow)) {
487 return errors.New("bounty: work window has not elapsed")
488 }
489 default:
490 return ErrUnauthorized
491 }
492 for _, m := range b.Milestones {
493 if m.Status == MilestoneSubmitted || m.Status == MilestoneDisputed {
494 return errors.New("bounty: a milestone is awaiting review or arbitration")
495 }
496 }
497 for _, m := range b.Milestones {
498 if m.Status == MilestoneChangesRequested {
499 m.Status = MilestonePending
500 m.Submission = ""
501 m.Worker = ""
502 }
503 }
504 b.Assignee = ""
505 b.AssignedAt = time.Time{}
506 b.Status = StatusOpen
507 return nil
508}
509
510// Submit hands a milestone in for review.
511func (b *Bounty) Submit(caller address, idx int, link string, now time.Time) error {
512 if b.Status != StatusActive {
513 return ErrBadState
514 }
515 if caller != b.Assignee {
516 return ErrUnauthorized
517 }
518 m, err := b.Milestone(idx)
519 if err != nil {
520 return err
521 }
522 if m.Status != MilestonePending && m.Status != MilestoneChangesRequested {
523 return ErrBadState
524 }
525 link = strings.TrimSpace(link)
526 if link == "" || len(link) > MaxLinkLen {
527 return errorf("bounty: submission link must be 1..%d bytes", MaxLinkLen)
528 }
529 m.Status = MilestoneSubmitted
530 m.Submission = link
531 m.SubmittedAt = now
532 m.Approvals = nil
533 m.Worker = caller
534 return nil
535}
536
537// Approve records a reviewer's approval. When the quorum is reached the
538// milestone is paid and the returned payout sends its full amount to the
539// worker.
540func (b *Bounty) Approve(caller address, idx int, now time.Time) ([]Payout, error) {
541 if b.Status != StatusActive {
542 return nil, ErrBadState
543 }
544 if !b.IsReviewer(caller) {
545 return nil, ErrUnauthorized
546 }
547 m, err := b.Milestone(idx)
548 if err != nil {
549 return nil, err
550 }
551 if m.Status != MilestoneSubmitted {
552 return nil, ErrBadState
553 }
554 if caller == m.Worker {
555 return nil, errors.New("bounty: workers cannot approve their own work")
556 }
557 for _, a := range m.Approvals {
558 if a == caller {
559 return nil, errors.New("bounty: already approved")
560 }
561 }
562 m.Approvals = append(m.Approvals, caller)
563 if len(m.Approvals) < b.Quorum {
564 return nil, nil
565 }
566 m.Status = MilestonePaid
567 m.PaidOut = m.Amount
568 m.SettleAt = now
569 b.maybeComplete(now)
570 return []Payout{{Kind: PayoutWork, To: m.Worker, Amount: m.Amount, Milestone: idx}}, nil
571}
572
573// RequestChanges sends a submitted milestone back to the worker, discarding
574// approvals collected so far.
575func (b *Bounty) RequestChanges(caller address, idx int, feedback string) error {
576 if b.Status != StatusActive {
577 return ErrBadState
578 }
579 if !b.IsReviewer(caller) {
580 return ErrUnauthorized
581 }
582 m, err := b.Milestone(idx)
583 if err != nil {
584 return err
585 }
586 if m.Status != MilestoneSubmitted {
587 return ErrBadState
588 }
589 if len(feedback) > MaxNoteLen {
590 return errorf("bounty: feedback exceeds %d bytes", MaxNoteLen)
591 }
592 m.Status = MilestoneChangesRequested
593 m.Feedback = feedback
594 m.Approvals = nil
595 return nil
596}
597
598// OpenDispute escalates a submitted or rejected milestone to the arbiter.
599// Either the poster or the worker who submitted it may open a dispute.
600func (b *Bounty) OpenDispute(caller address, idx int, reason string, now time.Time) error {
601 if b.Status != StatusActive {
602 return ErrBadState
603 }
604 m, err := b.Milestone(idx)
605 if err != nil {
606 return err
607 }
608 if caller != b.Poster && caller != m.Worker {
609 return ErrUnauthorized
610 }
611 if m.Status != MilestoneSubmitted && m.Status != MilestoneChangesRequested {
612 return ErrBadState
613 }
614 reason = strings.TrimSpace(reason)
615 if reason == "" || len(reason) > MaxNoteLen {
616 return errorf("bounty: reason must be 1..%d bytes", MaxNoteLen)
617 }
618 m.Status = MilestoneDisputed
619 m.Dispute = &Dispute{OpenedBy: caller, Reason: reason, OpenedAt: now}
620 return nil
621}
622
623// Resolve settles a disputed milestone. The arbiter awards workerBps of the
624// milestone to the worker and refunds the rest to the poster.
625func (b *Bounty) Resolve(caller address, idx int, workerBps int64, ruling string, now time.Time) ([]Payout, error) {
626 if caller != b.Arbiter {
627 return nil, ErrUnauthorized
628 }
629 m, err := b.Milestone(idx)
630 if err != nil {
631 return nil, err
632 }
633 if m.Status != MilestoneDisputed || m.Dispute == nil {
634 return nil, ErrBadState
635 }
636 if workerBps < 0 || workerBps > MaxBps {
637 return nil, errors.New("bounty: worker share must be within 0..10000 bps")
638 }
639 if len(ruling) > MaxNoteLen {
640 return nil, errorf("bounty: ruling exceeds %d bytes", MaxNoteLen)
641 }
642 toWorker := m.Amount * workerBps / MaxBps
643 toPoster := m.Amount - toWorker
644
645 m.Dispute.Resolved = true
646 m.Dispute.ResolvedAt = now
647 m.Dispute.WorkerBps = workerBps
648 m.Dispute.Ruling = ruling
649 m.PaidOut = toWorker
650 m.Refunded = toPoster
651 m.SettleAt = now
652 switch {
653 case toPoster == 0:
654 m.Status = MilestonePaid
655 case toWorker == 0:
656 m.Status = MilestoneRefunded
657 default:
658 m.Status = MilestoneSplit
659 }
660
661 var payouts []Payout
662 if toWorker > 0 {
663 payouts = append(payouts, Payout{Kind: PayoutWork, To: m.Worker, Amount: toWorker, Milestone: idx})
664 }
665 if toPoster > 0 {
666 payouts = append(payouts, Payout{Kind: PayoutRefund, To: b.Poster, Amount: toPoster, Milestone: idx})
667 }
668 b.maybeComplete(now)
669 return payouts, nil
670}
671
672// Cancel withdraws an open bounty and refunds every unsettled milestone. An
673// active bounty must be unassigned first, so a poster cannot pull funds from
674// under someone who is working.
675func (b *Bounty) Cancel(caller address, now time.Time) ([]Payout, error) {
676 if caller != b.Poster {
677 return nil, ErrUnauthorized
678 }
679 if b.Status != StatusOpen {
680 return nil, ErrBadState
681 }
682 var payouts []Payout
683 for i, m := range b.Milestones {
684 if m.Status.Settled() {
685 continue
686 }
687 m.Status = MilestoneRefunded
688 m.Refunded = m.Amount
689 m.SettleAt = now
690 payouts = append(payouts, Payout{Kind: PayoutRefund, To: b.Poster, Amount: m.Amount, Milestone: i})
691 }
692 b.Status = StatusCancelled
693 b.ClosedAt = now
694 return payouts, nil
695}
696
697// Involves reports whether addr plays any role in the bounty.
698func (b *Bounty) Involves(addr address) bool {
699 return addr == b.Poster || addr == b.Assignee || addr == b.Arbiter || b.IsReviewer(addr)
700}
701
702func (b *Bounty) maybeComplete(now time.Time) {
703 for _, m := range b.Milestones {
704 if !m.Status.Settled() {
705 return
706 }
707 }
708 b.Status = StatusCompleted
709 b.ClosedAt = now
710}
711
712func normalizeTags(in []string) ([]string, error) {
713 var out []string
714 seen := map[string]bool{}
715 for _, t := range in {
716 t = strings.ToLower(strings.TrimSpace(t))
717 if t == "" || seen[t] {
718 continue
719 }
720 if len(t) > MaxTagLen {
721 return nil, errorf("bounty: tag %q exceeds %d bytes", t, MaxTagLen)
722 }
723 for _, r := range t {
724 if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
725 return nil, errors.New("bounty: tags may only contain a-z, 0-9 and '-'")
726 }
727 }
728 seen[t] = true
729 out = append(out, t)
730 }
731 if len(out) > MaxTags {
732 return nil, errorf("bounty: at most %d tags", MaxTags)
733 }
734 return out, nil
735}
736
737func parseAmount(s string) (int64, error) {
738 if s == "" {
739 return 0, errors.New("bounty: empty amount")
740 }
741 n := int64(0)
742 for _, r := range s {
743 if r < '0' || r > '9' {
744 return 0, errors.New("bounty: amount must be a positive integer: " + s)
745 }
746 d := int64(r - '0')
747 if n > (9223372036854775807-d)/10 {
748 return 0, errors.New("bounty: amount overflows")
749 }
750 n = n*10 + d
751 }
752 if n <= 0 {
753 return 0, errors.New("bounty: amount must be positive")
754 }
755 return n, nil
756}
757
758func errorf(format string, args ...any) error {
759 return errors.New(ufmt.Sprintf(format, args...))
760}