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.

bounties.gno

13.78 Kb · 447 lines
  1// Package bounties is Guildhall's escrowed bounty board.
  2//
  3// Anyone can post a task, fund it with GNOT (or any native coin) or any token
  4// in the grc20 registry, and split it into milestones. Funds sit in this
  5// realm until the bounty's reviewers approve a milestone, at which point the
  6// milestone is paid to the contributor and a record of the work is written to
  7// gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation. Contested milestones go to an arbiter, who
  8// can pay the worker, refund the poster, or split the difference.
  9//
 10// The rules live in gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounty. This realm holds the funds,
 11// authenticates callers, moves coins, and renders the board.
 12package bounties
 13
 14import (
 15	"chain"
 16	"chain/banker"
 17	"chain/runtime/unsafe"
 18	"strconv"
 19	"strings"
 20	"time"
 21	"unicode/utf8"
 22
 23	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounty/v0"
 24	"gno.land/p/nt/avl/v0"
 25	"gno.land/p/nt/ownable/v0"
 26	"gno.land/p/nt/ufmt/v0"
 27	"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
 28	"gno.land/r/nt/grc20reg/v0"
 29)
 30
 31const maxWorkWindowDays = 365
 32
 33var (
 34	bounties avl.Tree // padded id -> *bounty.Bounty
 35	lastID   uint64
 36	byUser   avl.Tree // address -> *avl.Tree(padded id -> struct{})
 37	// councilArbitrated holds the ids of bounties whose arbiter is "the
 38	// council" rather than a fixed address, so they follow the council if
 39	// it changes hands.
 40	councilArbitrated avl.Tree
 41	statusCount       = map[bounty.Status]int{}
 42	escrowed          avl.Tree // denom key -> int64 currently held
 43
 44	council *ownable.Ownable
 45	paused  bool
 46)
 47
 48// InitialCouncil owns this namespace and is the only key that can deploy
 49// here. It holds the council seat until TransferCouncil hands it on.
 50const InitialCouncil address = "g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm"
 51
 52func init() {
 53	council = ownable.NewWithAddress(InitialCouncil)
 54}
 55
 56// ---------------------------------------------------------------------------
 57// Posting
 58
 59// PostBounty posts a bounty funded with the native coins sent along with the
 60// call. milestones is "Title=amount;Title=amount" and must add up to exactly
 61// what was sent; leave it empty for a single milestone worth the whole
 62// amount. reviewers is a comma-separated address list (empty means the poster
 63// reviews alone) and quorum is how many of them must approve each milestone.
 64// An empty arbiter delegates disputes to the Guildhall council.
 65func PostBounty(cur realm, title, description, link, tags, reviewers string, quorum int, milestones, arbiter string, workWindowDays int) uint64 {
 66	assertNotPaused()
 67	if !cur.Previous().IsUserCall() {
 68		panic("bounties: native bounties must be posted directly by a user (maketx call with -send)")
 69	}
 70	sent := unsafe.OriginSend()
 71	if len(sent) != 1 || sent[0].Amount <= 0 {
 72		panic("bounties: send exactly one coin denomination to fund the bounty")
 73	}
 74	coin := sent[0]
 75	specs, err := bounty.ParseMilestones(milestones, coin.Amount)
 76	if err != nil {
 77		panic(err.Error())
 78	}
 79	b := newBounty(cur, coin.Denom, false, title, description, link, tags, reviewers, quorum, specs, arbiter, workWindowDays)
 80	if b.Total() != coin.Amount {
 81		panic(ufmt.Sprintf("bounties: milestones add up to %d but %d%s was sent", b.Total(), coin.Amount, coin.Denom))
 82	}
 83	return store(b)
 84}
 85
 86// PostBountyGRC20 posts a bounty funded with a registered GRC20 token. The
 87// caller must first approve this realm's address for the milestone total on
 88// the token; this call pulls the funds into escrow.
 89func PostBountyGRC20(cur realm, tokenKey, title, description, link, tags, reviewers string, quorum int, milestones, arbiter string, workWindowDays int) uint64 {
 90	assertNotPaused()
 91	if grc20reg.Get(tokenKey) == nil {
 92		panic("bounties: unknown token " + strconv.Quote(tokenKey))
 93	}
 94	if len(unsafe.OriginSend()) > 0 && cur.Previous().IsUserCall() {
 95		panic("bounties: do not send native coins with a GRC20 bounty")
 96	}
 97	specs, err := bounty.ParseMilestones(milestones, 0)
 98	if err != nil {
 99		panic(err.Error())
100	}
101	b := newBounty(cur, tokenKey, true, title, description, link, tags, reviewers, quorum, specs, arbiter, workWindowDays)
102	grc20reg.TransferFrom(0, cur, tokenKey, b.Poster, cur.Address(), b.Total())
103	return store(b)
104}
105
106func newBounty(cur realm, denom string, isGRC20 bool, title, description, link, tags, reviewers string, quorum int, specs []bounty.MilestoneSpec, arbiter string, workWindowDays int) *bounty.Bounty {
107	if workWindowDays < 0 || workWindowDays > maxWorkWindowDays {
108		panic(ufmt.Sprintf("bounties: work window must be 0..%d days", maxWorkWindowDays))
109	}
110	arb := address(strings.TrimSpace(arbiter))
111	if arb == "" {
112		arb = council.Owner()
113	}
114	b, err := bounty.New(bounty.Params{
115		ID:          lastID + 1,
116		Poster:      cur.Previous().Address(),
117		Title:       title,
118		Description: description,
119		Link:        strings.TrimSpace(link),
120		Tags:        strings.Split(tags, ","),
121		Denom:       denom,
122		IsGRC20:     isGRC20,
123		Reviewers:   bounty.ParseAddresses(reviewers),
124		Quorum:      quorum,
125		Arbiter:     arb,
126		Milestones:  specs,
127		WorkWindow:  time.Duration(workWindowDays) * 24 * time.Hour,
128		Now:         time.Now(),
129	})
130	if err != nil {
131		panic(err.Error())
132	}
133	if strings.TrimSpace(arbiter) == "" {
134		councilArbitrated.Set(idKey(b.ID), struct{}{})
135	}
136	return b
137}
138
139func store(b *bounty.Bounty) uint64 {
140	lastID = b.ID
141	bounties.Set(idKey(b.ID), b)
142	statusCount[b.Status]++
143	addEscrow(b, b.Total())
144	index(b.Poster, b.ID)
145	for _, r := range b.Reviewers {
146		index(r, b.ID)
147	}
148	index(b.Arbiter, b.ID)
149	emit("BountyPosted", b,
150		"poster", b.Poster.String(),
151		"denom", b.Denom,
152		"total", strconv.FormatInt(b.Total(), 10))
153	return b.ID
154}
155
156// ---------------------------------------------------------------------------
157// Working on a bounty
158
159// Apply asks the poster to be assigned to an open bounty.
160func Apply(cur realm, id uint64, pitch string) {
161	b := mustGet(id)
162	caller := cur.Previous().Address()
163	check(b.Apply(caller, pitch, time.Now()))
164	index(caller, id)
165	emit("BountyApplied", b, "applicant", caller.String())
166}
167
168// Assign hands an open bounty to a contributor. Poster only.
169func Assign(cur realm, id uint64, assignee address) {
170	b := mustGet(id)
171	before := b.Status
172	check(b.Assign(cur.Previous().Address(), assignee, time.Now()))
173	moved(b, before)
174	index(assignee, id)
175	emit("BountyAssigned", b, "assignee", assignee.String())
176}
177
178// Unassign returns an active bounty to the open pool. The assignee may step
179// back at any time; the poster may reclaim it once the work window has
180// passed. Milestones awaiting review or arbitration must be settled first.
181func Unassign(cur realm, id uint64) {
182	b := mustGet(id)
183	before, prev := b.Status, b.Assignee
184	check(b.Unassign(cur.Previous().Address(), time.Now()))
185	moved(b, before)
186	emit("BountyUnassigned", b, "previous", prev.String())
187}
188
189// Submit hands milestone (1-based) in for review, pointing at the work.
190func Submit(cur realm, id uint64, milestone int, link string) {
191	b := mustGet(id)
192	check(b.Submit(cur.Previous().Address(), milestone-1, link, time.Now()))
193	emit("MilestoneSubmitted", b, "milestone", strconv.Itoa(milestone), "link", link)
194}
195
196// Approve records a reviewer's approval of a submitted milestone. The
197// approval that reaches the quorum releases the milestone's funds to the
198// contributor and writes a reputation record.
199func Approve(cur realm, id uint64, milestone int) {
200	b := mustGet(id)
201	caller := cur.Previous().Address()
202	before := b.Status
203	payouts, err := b.Approve(caller, milestone-1, time.Now())
204	check(err)
205	emit("MilestoneApproved", b, "milestone", strconv.Itoa(milestone), "reviewer", caller.String())
206	m, _ := b.Milestone(milestone - 1)
207	settle(0, cur, b, payouts, m.Approvals, false)
208	moved(b, before)
209}
210
211// RequestChanges sends a submitted milestone back with feedback.
212func RequestChanges(cur realm, id uint64, milestone int, feedback string) {
213	b := mustGet(id)
214	check(b.RequestChanges(cur.Previous().Address(), milestone-1, feedback))
215	emit("MilestoneChangesRequested", b, "milestone", strconv.Itoa(milestone))
216}
217
218// OpenDispute escalates a submitted or rejected milestone to the arbiter.
219// The poster or the contributor who submitted it may open one.
220func OpenDispute(cur realm, id uint64, milestone int, reason string) {
221	b := mustGet(id)
222	caller := cur.Previous().Address()
223	check(b.OpenDispute(caller, milestone-1, reason, time.Now()))
224	emit("MilestoneDisputed", b, "milestone", strconv.Itoa(milestone), "by", caller.String())
225}
226
227// Resolve settles a disputed milestone. workerBps is the share, in basis
228// points (0..10000), paid to the contributor; the rest is refunded to the
229// poster. Arbiter only.
230func Resolve(cur realm, id uint64, milestone int, workerBps int, ruling string) {
231	b := mustGet(id)
232	caller := cur.Previous().Address()
233	if councilArbitrated.Has(idKey(id)) {
234		// Council-arbitrated bounties follow the council's current owner.
235		b.Arbiter = council.Owner()
236	}
237	before := b.Status
238	payouts, err := b.Resolve(caller, milestone-1, int64(workerBps), ruling, time.Now())
239	check(err)
240	emit("DisputeResolved", b, "milestone", strconv.Itoa(milestone), "worker_bps", strconv.Itoa(workerBps))
241	settle(0, cur, b, payouts, []address{caller}, true)
242	moved(b, before)
243}
244
245// Cancel withdraws an open bounty and refunds every unpaid milestone to the
246// poster. Active bounties must be unassigned first.
247func Cancel(cur realm, id uint64) {
248	b := mustGet(id)
249	before := b.Status
250	payouts, err := b.Cancel(cur.Previous().Address(), time.Now())
251	check(err)
252	settle(0, cur, b, payouts, nil, false)
253	moved(b, before)
254	emit("BountyCancelled", b)
255}
256
257// ---------------------------------------------------------------------------
258// Council
259
260// Council returns the default arbiter and operator of the board.
261func Council() address {
262	return council.Owner()
263}
264
265// TransferCouncil hands the council seat to a new address, typically an
266// arbitration committee's multisig. Bounties posted with the council as
267// arbiter follow. Only the current council can move the seat.
268func TransferCouncil(cur realm, newCouncil address) {
269	if err := council.TransferOwnership(0, cur, newCouncil); err != nil {
270		panic(err.Error())
271	}
272	emitCouncil(newCouncil)
273}
274
275func emitCouncil(addr address) {
276	chain.Emit("BountiesCouncilChanged", "council", addr.String())
277}
278
279// SetPaused stops (or resumes) the posting of new bounties. Work on existing
280// bounties, payouts and refunds are never paused.
281func SetPaused(cur realm, p bool) {
282	council.AssertOwnedBy(cur.Previous().Address())
283	paused = p
284	chain.Emit("BountiesPaused", "paused", strconv.FormatBool(p))
285}
286
287// ---------------------------------------------------------------------------
288// Queries
289
290// Count returns the number of bounties ever posted.
291func Count() int {
292	return bounties.Size()
293}
294
295// CountByStatus returns the number of bounties in status ("open", "active",
296// "completed" or "cancelled").
297func CountByStatus(status string) int {
298	s, ok := bounty.ParseStatus(status)
299	if !ok {
300		return 0
301	}
302	return statusCount[s]
303}
304
305// Escrowed returns what the board currently holds for denom (a native denom
306// or a grc20reg key).
307func Escrowed(denom string) int64 {
308	v := escrowed.Get(denom)
309	if v == nil {
310		return 0
311	}
312	return v.(int64)
313}
314
315// IsPaused reports whether posting is paused.
316func IsPaused() bool {
317	return paused
318}
319
320// ---------------------------------------------------------------------------
321// Internals
322
323// settle executes payouts, crediting reputation for each payment to a worker.
324func settle(_ int, rlm realm, b *bounty.Bounty, payouts []bounty.Payout, reviewers []address, arbitrated bool) {
325	for _, p := range payouts {
326		transfer(0, rlm, b, p.To, p.Amount)
327		addEscrow(b, -p.Amount)
328		m, _ := b.Milestone(p.Milestone)
329		emit("Payout", b,
330			"kind", string(p.Kind),
331			"to", p.To.String(),
332			"amount", strconv.FormatInt(p.Amount, 10),
333			"milestone", strconv.Itoa(p.Milestone+1))
334		if p.Kind != bounty.PayoutWork {
335			continue
336		}
337		share := int64(bounty.MaxBps)
338		if arbitrated && m.Amount > 0 {
339			share = p.Amount * bounty.MaxBps / m.Amount
340		}
341		reputation.AddRecord(cross(rlm),
342			p.To,
343			b.Poster,
344			reviewers,
345			share,
346			p.Amount,
347			b.Denom,
348			clip(b.Title+" — "+m.Title, 300),
349			m.Submission,
350			ufmt.Sprintf("%d/%d", b.ID, p.Milestone+1),
351			arbitrated,
352		)
353	}
354}
355
356func transfer(_ int, rlm realm, b *bounty.Bounty, to address, amount int64) {
357	if amount <= 0 {
358		return
359	}
360	if b.IsGRC20 {
361		grc20reg.Transfer(0, rlm, b.Denom, to, amount)
362		return
363	}
364	bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
365	bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: b.Denom, Amount: amount}})
366}
367
368func mustGet(id uint64) *bounty.Bounty {
369	v := bounties.Get(idKey(id))
370	if v == nil {
371		panic("bounties: unknown bounty " + strconv.FormatUint(id, 10))
372	}
373	return v.(*bounty.Bounty)
374}
375
376func getBounty(id uint64) *bounty.Bounty {
377	v := bounties.Get(idKey(id))
378	if v == nil {
379		return nil
380	}
381	return v.(*bounty.Bounty)
382}
383
384func moved(b *bounty.Bounty, before bounty.Status) {
385	if b.Status == before {
386		return
387	}
388	statusCount[before]--
389	statusCount[b.Status]++
390}
391
392func addEscrow(b *bounty.Bounty, delta int64) {
393	cur := int64(0)
394	if v := escrowed.Get(b.Denom); v != nil {
395		cur = v.(int64)
396	}
397	escrowed.Set(b.Denom, cur+delta)
398}
399
400func index(addr address, id uint64) {
401	if addr == "" {
402		return
403	}
404	var t *avl.Tree
405	if v := byUser.Get(addr.String()); v != nil {
406		t = v.(*avl.Tree)
407	} else {
408		t = avl.NewTree()
409		byUser.Set(addr.String(), t)
410	}
411	t.Set(idKey(id), struct{}{})
412}
413
414func assertNotPaused() {
415	if paused {
416		panic("bounties: posting is paused by the council")
417	}
418}
419
420func check(err error) {
421	if err != nil {
422		panic(err.Error())
423	}
424}
425
426func emit(event string, b *bounty.Bounty, kv ...string) {
427	attrs := append([]string{"id", strconv.FormatUint(b.ID, 10), "status", b.Status.String()}, kv...)
428	chain.Emit(event, attrs...)
429}
430
431func idKey(id uint64) string {
432	s := strconv.FormatUint(id, 10)
433	for len(s) < 12 {
434		s = "0" + s
435	}
436	return s
437}
438
439func clip(s string, n int) string {
440	if len(s) <= n {
441		return s
442	}
443	for n > 0 && !utf8.RuneStart(s[n]) {
444		n--
445	}
446	return s[:n]
447}