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.

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}