reputation.gno
15.63 Kb · 619 lines
1// Package reputation is Guildhall's record of completed work.
2//
3// Every record says that a piece of work was paid for, who did it, who vouched
4// for it, how much it paid and where the work lives (a PR, a realm path, a
5// proposal). Records can only be written by allowlisted writer realms, the
6// Guildhall bounty escrow first among them, and only at the moment escrowed
7// funds are released. Each record also keeps the address whose signed
8// transaction released the funds.
9//
10// Scores are derived from those records with the rules in
11// gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/trust: a record is worth more when the people who
12// approved it are themselves trusted. Any realm can consume the result with a
13// single import:
14//
15// import "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
16//
17// func Vote(cur realm, ...) {
18// reputation.AssertScore(cur.Previous().Address(), 300)
19// ...
20// }
21package reputation
22
23import (
24 "chain"
25 "chain/runtime"
26 "chain/runtime/unsafe"
27 "errors"
28 "strconv"
29 "time"
30
31 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/trust/v0"
32 "gno.land/p/nt/avl/v0"
33 "gno.land/p/nt/ownable/v0"
34 "gno.land/p/nt/ufmt/v0"
35)
36
37// BountiesPath is the escrow realm allowed to write records at launch.
38const BountiesPath = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties"
39
40const (
41 maxReviewers = 16
42 maxTextLen = 300
43 maxRank = 1000
44)
45
46// entry is what a writer realm submits when work is paid for.
47type entry struct {
48 Worker address
49 Poster address
50 Reviewers []address
51 ShareBps int64
52 Amount int64
53 Denom string
54 Title string
55 Link string
56 Ref string
57 Arbitrated bool
58}
59
60// Record is a stored, immutable account of paid work. Only Voided can change,
61// and only by the Guildhall admin.
62type Record struct {
63 ID uint64
64 Worker address
65 Poster address
66 Reviewers []address
67 ReviewerTrust []int64
68 Points int64
69 ShareBps int64
70 Amount int64
71 Denom string
72 Title string
73 Link string
74 Ref string
75 Source string
76 SignedBy address
77 Arbitrated bool
78 Height int64
79 At time.Time
80 Voided bool
81 VoidReason string
82}
83
84// Profile aggregates a contributor's records.
85type Profile struct {
86 Addr address
87 Score int64
88 Records []uint64
89 Reviews []uint64
90 First time.Time
91 Last time.Time
92 earned *avl.Tree // denom -> int64
93}
94
95// Standing is a leaderboard entry.
96type Standing struct {
97 Addr address
98 Score int64
99}
100
101var (
102 records avl.Tree // padded id -> *Record
103 lastID uint64
104 profiles avl.Tree // address -> *Profile
105 leaderboard avl.Tree // inverted score key -> address
106 seeds avl.Tree // address -> struct{}
107 writers avl.Tree // pkgpath -> struct{}
108 params = trust.DefaultParams()
109 admin *ownable.Ownable
110)
111
112// InitialAdmin owns this namespace and is the only key that can deploy here.
113// It starts out as the admin; TransferAdmin can hand the role on.
114const InitialAdmin address = "g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm"
115
116func init() {
117 admin = ownable.NewWithAddress(InitialAdmin)
118 writers.Set(BountiesPath, struct{}{})
119}
120
121// ---------------------------------------------------------------------------
122// Writing
123
124// AddRecord stores a piece of paid work and credits the worker. It may only be
125// called by an allowlisted writer realm, at the moment it releases funds.
126//
127// worker was paid amount of denom for work funded by poster and approved by
128// reviewers. shareBps is the fraction of the milestone the worker received
129// (10000 unless a dispute split it). title, link and ref describe the work:
130// link points at the PR, realm path or proposal, and ref identifies the
131// work within the writer realm (for bounties, "<id>/<milestone>").
132func AddRecord(cur realm, worker, poster address, reviewers []address, shareBps, amount int64, denom, title, link, ref string, arbitrated bool) uint64 {
133 e := entry{
134 Worker: worker,
135 Poster: poster,
136 Reviewers: reviewers,
137 ShareBps: shareBps,
138 Amount: amount,
139 Denom: denom,
140 Title: title,
141 Link: link,
142 Ref: ref,
143 Arbitrated: arbitrated,
144 }
145 src := cur.Previous().PkgPath()
146 if src == "" || !writers.Has(src) {
147 panic("reputation: " + strconv.Quote(src) + " is not an allowed writer")
148 }
149 if err := validateEntry(e); err != nil {
150 panic(err.Error())
151 }
152
153 vouchers := make([]trust.Reviewer, len(e.Reviewers))
154 for i, r := range e.Reviewers {
155 vouchers[i] = trust.Reviewer{Addr: r, Score: Score(r), Seed: IsSeed(r)}
156 }
157 res, err := trust.Evaluate(trust.Input{
158 Worker: e.Worker,
159 Poster: e.Poster,
160 Reviewers: vouchers,
161 ShareBps: e.ShareBps,
162 }, params)
163 if err != nil {
164 panic(err.Error())
165 }
166
167 lastID++
168 rec := &Record{
169 ID: lastID,
170 Worker: e.Worker,
171 Poster: e.Poster,
172 Reviewers: append([]address{}, e.Reviewers...),
173 ReviewerTrust: res.TrustBps,
174 Points: res.Points,
175 ShareBps: e.ShareBps,
176 Amount: e.Amount,
177 Denom: e.Denom,
178 Title: e.Title,
179 Link: e.Link,
180 Ref: e.Ref,
181 Source: src,
182 SignedBy: unsafe.OriginCaller(),
183 Arbitrated: e.Arbitrated,
184 Height: runtime.ChainHeight(),
185 At: time.Now(),
186 }
187 records.Set(idKey(rec.ID), rec)
188
189 wp := getOrCreateProfile(e.Worker)
190 wp.Records = append(wp.Records, rec.ID)
191 addEarned(wp, e.Denom, e.Amount)
192 touch(wp, rec.At)
193 setScore(wp, wp.Score+rec.Points)
194
195 seen := map[address]bool{}
196 for _, r := range e.Reviewers {
197 if seen[r] || r == e.Worker {
198 continue
199 }
200 seen[r] = true
201 rp := getOrCreateProfile(r)
202 rp.Reviews = append(rp.Reviews, rec.ID)
203 touch(rp, rec.At)
204 }
205
206 chain.Emit("GuildhallRecord",
207 "id", strconv.FormatUint(rec.ID, 10),
208 "worker", e.Worker.String(),
209 "points", strconv.FormatInt(rec.Points, 10),
210 "source", src,
211 "ref", e.Ref,
212 )
213 return rec.ID
214}
215
216func validateEntry(e entry) error {
217 if !e.Worker.IsValid() {
218 return errors.New("reputation: invalid worker address")
219 }
220 if !e.Poster.IsValid() {
221 return errors.New("reputation: invalid poster address")
222 }
223 if len(e.Reviewers) == 0 || len(e.Reviewers) > maxReviewers {
224 return errors.New("reputation: a record needs 1..16 reviewers")
225 }
226 for _, r := range e.Reviewers {
227 if !r.IsValid() {
228 return errors.New("reputation: invalid reviewer address")
229 }
230 }
231 if e.Amount < 0 {
232 return errors.New("reputation: negative amount")
233 }
234 if len(e.Title) > maxTextLen || len(e.Link) > maxTextLen || len(e.Ref) > maxTextLen || len(e.Denom) > maxTextLen {
235 return errors.New("reputation: field too long")
236 }
237 return nil
238}
239
240// ---------------------------------------------------------------------------
241// Reading: the API other realms import.
242
243// Score returns addr's current reputation score.
244func Score(addr address) int64 {
245 p := getProfile(addr)
246 if p == nil {
247 return 0
248 }
249 return p.Score
250}
251
252// Level returns a display name for addr's score band.
253func Level(addr address) string {
254 return trust.Level(Score(addr))
255}
256
257// HasScore reports whether addr's score is at least min.
258func HasScore(addr address, min int64) bool {
259 return Score(addr) >= min
260}
261
262// AssertScore panics unless addr's score is at least min. It is the one-line
263// gate for realms that want reputation-weighted access.
264func AssertScore(addr address, min int64) {
265 if s := Score(addr); s < min {
266 panic(ufmt.Sprintf("guildhall: reputation %d is below the required %d", s, min))
267 }
268}
269
270// CompletedCount returns how many paid records addr has as a worker.
271func CompletedCount(addr address) int {
272 p := getProfile(addr)
273 if p == nil {
274 return 0
275 }
276 return len(p.Records)
277}
278
279// ReviewCount returns how many records addr has vouched for.
280func ReviewCount(addr address) int {
281 p := getProfile(addr)
282 if p == nil {
283 return 0
284 }
285 return len(p.Reviews)
286}
287
288// Earned returns the total addr has been paid in denom through records.
289func Earned(addr address, denom string) int64 {
290 p := getProfile(addr)
291 if p == nil || p.earned == nil {
292 return 0
293 }
294 v := p.earned.Get(denom)
295 if v == nil {
296 return 0
297 }
298 return v.(int64)
299}
300
301// IsSeed reports whether addr is a seed reviewer.
302func IsSeed(addr address) bool {
303 return seeds.Has(addr.String())
304}
305
306// IsWriter reports whether pkgPath may write records.
307func IsWriter(pkgPath string) bool {
308 return writers.Has(pkgPath)
309}
310
311// RecordCount returns the number of records ever written.
312func RecordCount() int {
313 return records.Size()
314}
315
316// ContributorCount returns the number of addresses with a positive score.
317func ContributorCount() int {
318 return leaderboard.Size()
319}
320
321// GetRecord returns a copy of the record with the given id.
322func GetRecord(id uint64) (Record, bool) {
323 r := getRecord(id)
324 if r == nil {
325 return Record{}, false
326 }
327 return copyRecord(r), true
328}
329
330// RecordsOf returns up to limit of addr's work records, newest first,
331// skipping the first offset.
332func RecordsOf(addr address, offset, limit int) []Record {
333 p := getProfile(addr)
334 if p == nil {
335 return nil
336 }
337 return collect(p.Records, offset, limit)
338}
339
340// ReviewsOf returns up to limit of the records addr vouched for, newest first.
341func ReviewsOf(addr address, offset, limit int) []Record {
342 p := getProfile(addr)
343 if p == nil {
344 return nil
345 }
346 return collect(p.Reviews, offset, limit)
347}
348
349// Top returns the n highest scores, highest first. Ties are ordered by
350// address.
351func Top(n int) []Standing {
352 var out []Standing
353 leaderboard.Iterate("", "", func(_ string, v any) bool {
354 addr := v.(address)
355 out = append(out, Standing{Addr: addr, Score: Score(addr)})
356 return len(out) >= n
357 })
358 return out
359}
360
361// Rank returns addr's 1-based leaderboard position, or 0 if unranked.
362func Rank(addr address) int {
363 p := getProfile(addr)
364 if p == nil || p.Score <= 0 {
365 return 0
366 }
367 key := boardKey(p.Score, addr)
368 rank := 0
369 leaderboard.Iterate("", key, func(_ string, _ any) bool {
370 rank++
371 return rank > maxRank
372 })
373 if rank > maxRank {
374 return 0
375 }
376 return rank + 1
377}
378
379// Params returns the scoring parameters in force.
380func Params() trust.Params {
381 return params
382}
383
384// Admin returns the address allowed to curate seeds, writers and parameters.
385func Admin() address {
386 return admin.Owner()
387}
388
389// ---------------------------------------------------------------------------
390// Governance
391
392//
393// Guildhall governs itself: the admin, and nobody else, curates seeds,
394// writers, parameters and voids. No outside realm has authority here. Hand
395// the role to a multisig with TransferAdmin to share it.
396
397// AddSeed marks addr as a seed reviewer with full trust.
398func AddSeed(cur realm, addr address) {
399 assertAdmin(cur)
400 addSeed(addr)
401}
402
403// RemoveSeed revokes addr's seed status. Existing records keep the points
404// they were awarded; only future reviews are affected.
405func RemoveSeed(cur realm, addr address) {
406 assertAdmin(cur)
407 removeSeed(addr)
408}
409
410// AddWriter allows another realm to write records.
411func AddWriter(cur realm, pkgPath string) {
412 assertAdmin(cur)
413 addWriter(pkgPath)
414}
415
416// RemoveWriter revokes a realm's right to write records.
417func RemoveWriter(cur realm, pkgPath string) {
418 assertAdmin(cur)
419 removeWriter(pkgPath)
420}
421
422// SetParams changes the scoring rules for future records.
423func SetParams(cur realm, base, trustThreshold, trustFloorBps, selfFundedFactorBps int64) {
424 assertAdmin(cur)
425 setParams(newParams(base, trustThreshold, trustFloorBps, selfFundedFactorBps))
426}
427
428// VoidRecord cancels a fraudulent record and removes its points from the
429// worker. Trust the worker lent to others while holding those points is not
430// unwound; voiding is for correcting abuse, not rewriting history.
431func VoidRecord(cur realm, id uint64, reason string) {
432 assertAdmin(cur)
433 checkVoid(id, reason)
434 voidRecord(id, reason)
435}
436
437// TransferAdmin hands the admin key to another address, such as a multisig.
438func TransferAdmin(cur realm, newAdmin address) {
439 if err := admin.TransferOwnership(0, cur, newAdmin); err != nil {
440 panic(err.Error())
441 }
442}
443
444// RenounceAdmin gives up the admin key for good. Nothing can curate
445// reputation afterwards: seeds, writers and parameters are frozen and no
446// record can be voided. Prefer TransferAdmin unless that is the intent.
447func RenounceAdmin(cur realm) {
448 if err := admin.DropOwnership(0, cur); err != nil {
449 panic(err.Error())
450 }
451}
452
453func addSeed(addr address) {
454 if !addr.IsValid() {
455 panic("reputation: invalid address")
456 }
457 seeds.Set(addr.String(), struct{}{})
458 chain.Emit("GuildhallSeedAdded", "addr", addr.String())
459}
460
461func removeSeed(addr address) {
462 if _, ok := seeds.Remove(addr.String()); !ok {
463 panic("reputation: not a seed")
464 }
465 chain.Emit("GuildhallSeedRemoved", "addr", addr.String())
466}
467
468func addWriter(pkgPath string) {
469 if pkgPath == "" {
470 panic("reputation: empty pkgpath")
471 }
472 writers.Set(pkgPath, struct{}{})
473 chain.Emit("GuildhallWriterAdded", "pkgpath", pkgPath)
474}
475
476func removeWriter(pkgPath string) {
477 if _, ok := writers.Remove(pkgPath); !ok {
478 panic("reputation: not a writer")
479 }
480 chain.Emit("GuildhallWriterRemoved", "pkgpath", pkgPath)
481}
482
483func newParams(base, trustThreshold, trustFloorBps, selfFundedFactorBps int64) trust.Params {
484 p := trust.Params{
485 Base: base,
486 TrustThreshold: trustThreshold,
487 TrustFloorBps: trustFloorBps,
488 SelfFundedFactorBps: selfFundedFactorBps,
489 }
490 if err := p.Validate(); err != nil {
491 panic(err.Error())
492 }
493 return p
494}
495
496func setParams(p trust.Params) {
497 params = p
498 chain.Emit("GuildhallParamsChanged")
499}
500
501func checkVoid(id uint64, reason string) {
502 r := getRecord(id)
503 if r == nil {
504 panic("reputation: unknown record")
505 }
506 if r.Voided {
507 panic("reputation: record already voided")
508 }
509 if reason == "" || len(reason) > maxTextLen {
510 panic("reputation: a reason of 1..300 bytes is required")
511 }
512}
513
514func voidRecord(id uint64, reason string) {
515 checkVoid(id, reason)
516 r := getRecord(id)
517 r.Voided = true
518 r.VoidReason = reason
519 p := getProfile(r.Worker)
520 setScore(p, p.Score-r.Points)
521 chain.Emit("GuildhallRecordVoided", "id", strconv.FormatUint(id, 10), "reason", reason)
522}
523
524func assertAdmin(cur realm) {
525 admin.AssertOwnedBy(cur.Previous().Address())
526}
527
528// ---------------------------------------------------------------------------
529// Internals
530
531func getProfile(addr address) *Profile {
532 v := profiles.Get(addr.String())
533 if v == nil {
534 return nil
535 }
536 return v.(*Profile)
537}
538
539func getOrCreateProfile(addr address) *Profile {
540 if p := getProfile(addr); p != nil {
541 return p
542 }
543 p := &Profile{Addr: addr, earned: avl.NewTree()}
544 profiles.Set(addr.String(), p)
545 return p
546}
547
548func getRecord(id uint64) *Record {
549 v := records.Get(idKey(id))
550 if v == nil {
551 return nil
552 }
553 return v.(*Record)
554}
555
556func touch(p *Profile, at time.Time) {
557 if p.First.IsZero() {
558 p.First = at
559 }
560 p.Last = at
561}
562
563func addEarned(p *Profile, denom string, amount int64) {
564 cur := int64(0)
565 if v := p.earned.Get(denom); v != nil {
566 cur = v.(int64)
567 }
568 p.earned.Set(denom, cur+amount)
569}
570
571func setScore(p *Profile, score int64) {
572 if p.Score > 0 {
573 leaderboard.Remove(boardKey(p.Score, p.Addr))
574 }
575 if score < 0 {
576 score = 0
577 }
578 p.Score = score
579 if score > 0 {
580 leaderboard.Set(boardKey(score, p.Addr), p.Addr)
581 }
582}
583
584// boardKey sorts higher scores first under avl's ascending iteration.
585func boardKey(score int64, addr address) string {
586 return pad(9223372036854775807-score, 19) + ":" + addr.String()
587}
588
589func idKey(id uint64) string {
590 return pad(int64(id), 12)
591}
592
593func pad(n int64, width int) string {
594 s := strconv.FormatInt(n, 10)
595 for len(s) < width {
596 s = "0" + s
597 }
598 return s
599}
600
601func collect(ids []uint64, offset, limit int) []Record {
602 var out []Record
603 if offset < 0 {
604 offset = 0
605 }
606 for i := len(ids) - 1 - offset; i >= 0 && len(out) < limit; i-- {
607 if r := getRecord(ids[i]); r != nil {
608 out = append(out, copyRecord(r))
609 }
610 }
611 return out
612}
613
614func copyRecord(r *Record) Record {
615 c := *r
616 c.Reviewers = append([]address{}, r.Reviewers...)
617 c.ReviewerTrust = append([]int64{}, r.ReviewerTrust...)
618 return c
619}