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}