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.

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}