Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}