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}