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.

govdao.gno

8.44 Kb · 231 lines
  1// Package govdao is an optional, one-way adapter from Guildhall reputation to
  2// GovDAO membership. Guildhall does not depend on it and GovDAO has no
  3// authority over it or over any other Guildhall realm; it only reads
  4// reputation and builds proposals that GovDAO members may choose to submit.
  5//
  6// It turns a contributor's on-chain work records into two inputs for GovDAO:
  7//
  8//   - an eligibility check: a minimum reputation score per tier, and
  9//   - a portfolio: a summary of the contributor's paid, reviewed work, with
 10//     links to every record, generated from chain state rather than written
 11//     by hand.
 12//
 13// GovDAO only accepts proposals created directly by a member's transaction,
 14// so this realm never creates proposals itself. It builds the request; the
 15// member submits it from a `gnokey maketx run` script:
 16//
 17//	package main
 18//
 19//	import (
 20//		"gno.land/r/gov/dao"
 21//		guild "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/govdao"
 22//	)
 23//
 24//	func main(cur realm) {
 25//		req := guild.NewMemberRequest(cross(cur), "g1candidate...", "T2")
 26//		dao.MustCreateProposal(cross(cur), req)
 27//	}
 28//
 29// The thresholds are set by the Guildhall admin with SetThreshold. They are
 30// Guildhall's bar for the candidates it vouches for; GovDAO's vote decides.
 31package govdao
 32
 33import (
 34	"chain"
 35	"strconv"
 36	"strings"
 37
 38	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/jsonw/v0"
 39	"gno.land/p/nt/avl/v0"
 40	"gno.land/p/nt/markdown/sanitize/v0"
 41	"gno.land/p/nt/ufmt/v0"
 42	"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
 43	"gno.land/r/gov/dao"
 44	impl "gno.land/r/gov/dao/impl/v0"
 45	"gno.land/r/gov/dao/memberstore/v0"
 46)
 47
 48const (
 49	portfolioRecords = 10
 50	chainURL         = "https://gno.land"
 51)
 52
 53// thresholds maps tier -> minimum reputation score.
 54var thresholds avl.Tree
 55
 56func init() {
 57	thresholds.Set(memberstore.T1, int64(2000))
 58	thresholds.Set(memberstore.T2, int64(800))
 59	thresholds.Set(memberstore.T3, int64(300))
 60}
 61
 62// Threshold returns the minimum score required to be proposed for tier.
 63func Threshold(tier string) int64 {
 64	v := thresholds.Get(tier)
 65	if v == nil {
 66		return -1
 67	}
 68	return v.(int64)
 69}
 70
 71// Eligible reports whether addr meets the reputation bar for tier and, if
 72// not, why.
 73func Eligible(addr address, tier string) (bool, string) {
 74	min := Threshold(tier)
 75	if min < 0 {
 76		return false, "unknown tier " + strconv.Quote(tier)
 77	}
 78	if !addr.IsValid() {
 79		return false, "invalid address"
 80	}
 81	score := reputation.Score(addr)
 82	if score < min {
 83		return false, ufmt.Sprintf("reputation %d is below the %s threshold of %d", score, tier, min)
 84	}
 85	return true, ""
 86}
 87
 88// AssertEligible panics unless addr meets the reputation bar for tier.
 89func AssertEligible(addr address, tier string) {
 90	if ok, why := Eligible(addr, tier); !ok {
 91		panic("guildhall: " + why)
 92	}
 93}
 94
 95// Portfolio renders addr's Guildhall track record as markdown suitable for a
 96// GovDAO membership proposal.
 97func Portfolio(addr address) string {
 98	var b strings.Builder
 99	score := reputation.Score(addr)
100	b.WriteString(ufmt.Sprintf("**Guildhall reputation: %d (%s)**", score, reputation.Level(addr)))
101	if r := reputation.Rank(addr); r > 0 {
102		b.WriteString(ufmt.Sprintf(", ranked #%d", r))
103	}
104	b.WriteString("\n\n")
105	b.WriteString(ufmt.Sprintf("- Paid work records: %d\n", reputation.CompletedCount(addr)))
106	b.WriteString(ufmt.Sprintf("- Reviews given: %d\n", reputation.ReviewCount(addr)))
107	if gnot := reputation.Earned(addr, "ugnot"); gnot > 0 {
108		b.WriteString("- Earned through bounties: " + gnotString(gnot) + "\n")
109	}
110	b.WriteString(ufmt.Sprintf("- Full profile: %s/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation:u/%s\n\n", chainURL, addr.String()))
111
112	recs := reputation.RecordsOf(addr, 0, portfolioRecords)
113	if len(recs) > 0 {
114		b.WriteString("Most recent work:\n\n")
115		for _, r := range recs {
116			if r.Voided {
117				continue
118			}
119			b.WriteString(ufmt.Sprintf("- %s (+%d, record #%d)", sanitize.InlineText(r.Title), r.Points, r.ID))
120			if u := sanitize.URL(r.Link); u != "" && strings.HasPrefix(r.Link, "https://") {
121				b.WriteString(" · [work](" + u + ")")
122			}
123			b.WriteString("\n")
124		}
125		b.WriteString("\n")
126	}
127	b.WriteString("_Generated from on-chain records by gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/govdao._")
128	return b.String()
129}
130
131// NewMemberRequest builds a GovDAO proposal to add addr to tier, with its
132// Guildhall portfolio attached. It panics if addr does not meet the tier's
133// reputation threshold. GovDAO's own rules still apply: the signer must be a
134// member with an invitation point, and only T1 and T2 are proposal-gated.
135func NewMemberRequest(cur realm, addr address, tier string) dao.ProposalRequest {
136	AssertEligible(addr, tier)
137	return impl.NewAddMemberRequest(cross(cur), addr, tier, Portfolio(addr))
138}
139
140// SetThreshold changes the minimum score for tier. Only the Guildhall admin
141// (reputation.Admin) may call it: the bar is Guildhall's statement of who it
142// vouches for, and GovDAO is free to ignore it.
143func SetThreshold(cur realm, tier string, min int64) {
144	if cur.Previous().Address() != reputation.Admin() {
145		panic("guildhall: only the Guildhall admin may change thresholds")
146	}
147	validateThreshold(tier, min)
148	thresholds.Set(tier, min)
149	chain.Emit("GuildhallThresholdChanged", "tier", tier, "min", strconv.FormatInt(min, 10))
150}
151
152func validateThreshold(tier string, min int64) {
153	if _, ok := memberstore.GetTier(tier); !ok {
154		panic("guildhall: unknown tier " + strconv.Quote(tier))
155	}
156	if min < 0 {
157		panic("guildhall: threshold must not be negative")
158	}
159}
160
161// Render shows the thresholds and, for u/<address>, that address's
162// eligibility and generated portfolio.
163func Render(path string) string {
164	path = strings.Trim(path, "/")
165	if strings.HasPrefix(path, "api/candidate/") {
166		return apiCandidate(address(strings.TrimPrefix(path, "api/candidate/")))
167	}
168	if strings.HasPrefix(path, "u/") {
169		return renderCandidate(address(strings.TrimPrefix(path, "u/")))
170	}
171	var b strings.Builder
172	b.WriteString("# Guildhall × GovDAO\n\n")
173	b.WriteString("Guildhall reputation is an on-chain input to GovDAO membership. A contributor can be proposed for a tier once their score reaches its threshold, and the proposal carries a portfolio generated from their work records.\n\n")
174	b.WriteString("| Tier | Minimum reputation |\n|---|---:|\n")
175	thresholds.Iterate("", "", func(k string, v any) bool {
176		b.WriteString(ufmt.Sprintf("| %s | %d |\n", k, v.(int64)))
177		return false
178	})
179	b.WriteString("\nThresholds are set by the Guildhall admin. GovDAO's vote still decides every membership; Guildhall only vouches.\n\n")
180	b.WriteString("Check a candidate at `/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/govdao:u/<address>`.\n\n")
181	b.WriteString("## Proposing a member\n\n```go\npackage main\n\nimport (\n\t\"gno.land/r/gov/dao\"\n\tguild \"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/govdao\"\n)\n\nfunc main(cur realm) {\n\treq := guild.NewMemberRequest(cross(cur), \"g1candidate...\", \"T2\")\n\tdao.MustCreateProposal(cross(cur), req)\n}\n```\n\n")
182	b.WriteString("Run it with `gnokey maketx run` from a GovDAO member's key.\n")
183	return b.String()
184}
185
186func renderCandidate(addr address) string {
187	if !addr.IsValid() {
188		return "# Not found\n"
189	}
190	var b strings.Builder
191	b.WriteString("# Candidate " + addr.String() + "\n\n")
192	b.WriteString("| Tier | Required | Eligible |\n|---|---:|---|\n")
193	thresholds.Iterate("", "", func(k string, v any) bool {
194		mark := "no"
195		if ok, _ := Eligible(addr, k); ok {
196			mark = "yes"
197		}
198		b.WriteString(ufmt.Sprintf("| %s | %d | %s |\n", k, v.(int64), mark))
199		return false
200	})
201	b.WriteString("\n## Portfolio\n\n" + Portfolio(addr) + "\n")
202	return b.String()
203}
204
205// apiCandidate serves JSON for the web app: per-tier thresholds and whether
206// addr meets each.
207func apiCandidate(addr address) string {
208	if !addr.IsValid() {
209		return jsonw.Obj("error", jsonw.String("invalid address"))
210	}
211	var tiers []string
212	thresholds.Iterate("", "", func(k string, v any) bool {
213		ok, _ := Eligible(addr, k)
214		tiers = append(tiers, jsonw.Obj("tier", jsonw.String(k), "min", jsonw.Int(v.(int64)), "eligible", jsonw.Bool(ok)))
215		return false
216	})
217	return jsonw.Obj("address", jsonw.String(addr.String()), "score", jsonw.Int(reputation.Score(addr)), "tiers", jsonw.Arr(tiers))
218}
219
220func gnotString(ugnot int64) string {
221	whole := ugnot / 1_000_000
222	frac := ugnot % 1_000_000
223	if frac == 0 {
224		return strconv.FormatInt(whole, 10) + " GNOT"
225	}
226	fs := strconv.FormatInt(frac, 10)
227	for len(fs) < 6 {
228		fs = "0" + fs
229	}
230	return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(fs, "0") + " GNOT"
231}