gnomedao.gno
13.01 Kb · 526 lines
1package gnomedao
2
3import (
4 "chain/runtime"
5 "strings"
6 "strconv"
7)
8
9// ── Types ─────────────────────────────────────────────────
10
11type Member struct {
12 Address address
13 Power int
14 Roles []string
15}
16
17type Vote struct {
18 Voter address
19 Value string // "YES", "NO", "ABSTAIN"
20}
21
22type Proposal struct {
23 ID int
24 Title string
25 Description string
26 Category string
27 Author address
28 Status string // "ACTIVE", "ACCEPTED", "REJECTED", "EXECUTED"
29 Votes []Vote
30 YesVotes int
31 NoVotes int
32 Abstain int
33 TotalPower int
34 ActionType string // "none", "add_member", "remove_member", "assign_role"
35 ActionData string // serialized action params (e.g. "addr|power|role1,role2")
36}
37
38// ── State ─────────────────────────────────────────────────
39
40var (
41 name = "gnomedao"
42 description = "a dao to bring all gnomes together "
43 threshold = 51 // percentage required to pass
44 quorum = 48 // minimum participation % (0 = disabled)
45 members []Member
46 proposals []Proposal
47 nextID = 0
48 allowedCategories []string
49 allowedRoles []string
50 archived = false
51)
52
53func init() {
54 members = append(members, Member{Address: address("g1hy6zry03hg5d8le9s2w4fxme6236hkgd928dun"), Power: 1, Roles: []string{"admin"}})
55 allowedCategories = append(allowedCategories, "governance")
56 allowedRoles = append(allowedRoles, "admin")
57 allowedRoles = append(allowedRoles, "member")
58}
59
60// ── Queries ───────────────────────────────────────────────
61
62func Render(path string) string {
63 if path == "" {
64 return renderHome()
65 }
66 // Parse proposal ID from path
67 parts := strings.Split(path, "/")
68 if len(parts) >= 1 {
69 id, err := strconv.Atoi(parts[0])
70 if err == nil && id >= 0 && id < len(proposals) {
71 if len(parts) >= 2 && parts[1] == "votes" {
72 return renderVotes(id)
73 }
74 return renderProposal(id)
75 }
76 }
77 return "# Not Found"
78}
79
80func renderHome() string {
81 out := "# " + name + "\n"
82 out += description + "\n\n"
83 out += "Threshold: " + strconv.Itoa(threshold) + "% | Quorum: " + strconv.Itoa(quorum) + "%\n\n"
84 out += "## Members (" + strconv.Itoa(len(members)) + ")\n"
85 for _, m := range members {
86 out += "- " + string(m.Address) + " (roles: " + strings.Join(m.Roles, ", ") + ") | power: " + strconv.Itoa(m.Power) + "\n"
87 }
88 out += "\n## Proposals\n"
89 for i := len(proposals) - 1; i >= 0; i-- {
90 p := proposals[i]
91 out += "### [Prop #" + strconv.Itoa(p.ID) + " - " + p.Title + "](:" + strconv.Itoa(p.ID) + ")\n"
92 out += "Author: " + string(p.Author) + "\n\n"
93 out += "Category: " + p.Category + "\n\n"
94 out += "Status: " + p.Status + "\n\n---\n\n"
95 }
96 if len(proposals) == 0 {
97 out += "No proposals yet.\n"
98 }
99 return out
100}
101
102func renderProposal(id int) string {
103 p := proposals[id]
104 out := "# Prop #" + strconv.Itoa(p.ID) + " - " + p.Title + "\n"
105 out += p.Description + "\n\n"
106 out += "Author: " + string(p.Author) + "\n\n"
107 out += "Category: " + p.Category + "\n\n"
108 out += "Status: " + p.Status + "\n\n"
109 out += "YES: " + strconv.Itoa(p.YesVotes) + " | NO: " + strconv.Itoa(p.NoVotes) + " | ABSTAIN: " + strconv.Itoa(p.Abstain) + "\n"
110 out += "Total Power: " + strconv.Itoa(p.TotalPower) + "/" + strconv.Itoa(totalPower()) + "\n"
111 return out
112}
113
114func renderVotes(id int) string {
115 p := proposals[id]
116 out := "# Proposal #" + strconv.Itoa(p.ID) + " - Vote List\n\n"
117 out += "YES:\n"
118 for _, v := range p.Votes {
119 if v.Value == "YES" {
120 out += "- " + string(v.Voter) + "\n"
121 }
122 }
123 out += "\nNO:\n"
124 for _, v := range p.Votes {
125 if v.Value == "NO" {
126 out += "- " + string(v.Voter) + "\n"
127 }
128 }
129 out += "\nABSTAIN:\n"
130 for _, v := range p.Votes {
131 if v.Value == "ABSTAIN" {
132 out += "- " + string(v.Voter) + "\n"
133 }
134 }
135 return out
136}
137
138// ── Actions ───────────────────────────────────────────────
139
140func Propose(cur realm, title, desc, category string) int {
141 caller := runtime.PreviousRealm().Address()
142 assertNotArchived()
143 assertMember(caller)
144 assertCategory(category)
145 id := nextID
146 nextID++
147 proposals = append(proposals, Proposal{
148 ID: id,
149 Title: title,
150 Description: desc,
151 Category: category,
152 Author: caller,
153 Status: "ACTIVE",
154 ActionType: "none",
155 })
156 return id
157}
158
159func VoteOnProposal(cur realm, id int, vote string) {
160 caller := runtime.PreviousRealm().Address()
161 assertNotArchived()
162 assertMember(caller)
163 if id < 0 || id >= len(proposals) {
164 panic("invalid proposal ID")
165 }
166 p := &proposals[id]
167 if p.Status != "ACTIVE" {
168 panic("proposal is not active")
169 }
170 // Check for duplicate votes
171 for _, v := range p.Votes {
172 if v.Voter == caller {
173 panic("already voted")
174 }
175 }
176 power := getMemberPower(caller)
177 p.Votes = append(p.Votes, Vote{Voter: caller, Value: vote})
178 switch vote {
179 case "YES":
180 p.YesVotes += power
181 case "NO":
182 p.NoVotes += power
183 case "ABSTAIN":
184 p.Abstain += power
185 default:
186 panic("invalid vote: must be YES, NO, or ABSTAIN")
187 }
188 p.TotalPower += power
189 // Check quorum + threshold
190 tpow := totalPower()
191 if tpow > 0 {
192 quorumMet := quorum == 0 || (p.TotalPower * 100 / tpow >= quorum)
193 if quorumMet && p.YesVotes * 100 / tpow >= threshold {
194 p.Status = "ACCEPTED"
195 }
196 if quorumMet && p.NoVotes * 100 / tpow > (100 - threshold) {
197 p.Status = "REJECTED"
198 }
199 }
200}
201
202func ExecuteProposal(cur realm, id int) {
203 caller := runtime.PreviousRealm().Address()
204 assertMember(caller)
205 if id < 0 || id >= len(proposals) {
206 panic("invalid proposal ID")
207 }
208 p := &proposals[id]
209 if p.Status != "ACCEPTED" {
210 panic("proposal must be ACCEPTED to execute")
211 }
212 // Dispatch action
213 switch p.ActionType {
214 case "add_member":
215 executeAddMember(p.ActionData)
216 case "remove_member":
217 executeRemoveMember(p.ActionData)
218 case "assign_role":
219 executeAssignRole(p.ActionData)
220 case "none":
221 // Text-only proposal — no action
222 default:
223 panic("unknown action type: " + p.ActionType)
224 }
225 p.Status = "EXECUTED"
226}
227
228// ── Member Proposals (governance-gated) ───────────────────
229
230func ProposeAddMember(cur realm, targetAddr address, power int, roles string) int {
231 caller := runtime.PreviousRealm().Address()
232 assertNotArchived()
233 assertMember(caller)
234 // Validate target is not already a member
235 for _, m := range members {
236 if m.Address == targetAddr {
237 panic("address is already a member")
238 }
239 }
240 id := nextID
241 nextID++
242 title := "Add member " + string(targetAddr)[:10] + "... with power " + strconv.Itoa(power)
243 desc := "**Action**: Add Member\n**Address**: " + string(targetAddr) + "\n**Power**: " + strconv.Itoa(power) + "\n**Roles**: " + roles
244 data := string(targetAddr) + "|" + strconv.Itoa(power) + "|" + roles
245 proposals = append(proposals, Proposal{
246 ID: id,
247 Title: title,
248 Description: desc,
249 Category: "membership",
250 Author: caller,
251 Status: "ACTIVE",
252 ActionType: "add_member",
253 ActionData: data,
254 })
255 return id
256}
257
258func ProposeRemoveMember(cur realm, targetAddr address) int {
259 caller := runtime.PreviousRealm().Address()
260 assertNotArchived()
261 assertMember(caller)
262 assertMember(targetAddr) // target must be a member
263 id := nextID
264 nextID++
265 title := "Remove member " + string(targetAddr)[:10] + "..."
266 desc := "**Action**: Remove Member\n**Address**: " + string(targetAddr)
267 proposals = append(proposals, Proposal{
268 ID: id,
269 Title: title,
270 Description: desc,
271 Category: "membership",
272 Author: caller,
273 Status: "ACTIVE",
274 ActionType: "remove_member",
275 ActionData: string(targetAddr),
276 })
277 return id
278}
279
280func ProposeAssignRole(cur realm, targetAddr address, role string) int {
281 caller := runtime.PreviousRealm().Address()
282 assertNotArchived()
283 assertMember(caller)
284 assertMember(targetAddr) // target must be a member
285 assertRole(role)
286 id := nextID
287 nextID++
288 title := "Assign role " + strconv.Quote(role) + " to " + string(targetAddr)[:10] + "..."
289 desc := "**Action**: Assign Role\n**Address**: " + string(targetAddr) + "\n**Role**: " + role
290 proposals = append(proposals, Proposal{
291 ID: id,
292 Title: title,
293 Description: desc,
294 Category: "membership",
295 Author: caller,
296 Status: "ACTIVE",
297 ActionType: "assign_role",
298 ActionData: string(targetAddr) + "|" + role,
299 })
300 return id
301}
302
303// ── Action Executors (internal) ───────────────────────────
304
305func executeAddMember(data string) {
306 parts := strings.Split(data, "|")
307 if len(parts) != 3 {
308 panic("invalid add_member action data")
309 }
310 addr := address(parts[0])
311 power, err := strconv.Atoi(parts[1])
312 if err != nil {
313 panic("invalid power in action data")
314 }
315 roles := strings.Split(parts[2], ",")
316 // Check not already a member
317 for _, m := range members {
318 if m.Address == addr {
319 panic("address is already a member")
320 }
321 }
322 members = append(members, Member{Address: addr, Power: power, Roles: roles})
323}
324
325func executeRemoveMember(data string) {
326 addr := address(data)
327 // Prevent removing last admin
328 if hasRole(addr, "admin") {
329 adminCount := 0
330 for _, m := range members {
331 if hasRoleInternal(m, "admin") {
332 adminCount++
333 }
334 }
335 if adminCount <= 1 {
336 panic("cannot remove the last admin")
337 }
338 }
339 newMembers := []Member{}
340 found := false
341 for _, m := range members {
342 if m.Address == addr {
343 found = true
344 continue
345 }
346 newMembers = append(newMembers, m)
347 }
348 if !found {
349 panic("member not found")
350 }
351 members = newMembers
352}
353
354func executeAssignRole(data string) {
355 parts := strings.Split(data, "|")
356 if len(parts) != 2 {
357 panic("invalid assign_role action data")
358 }
359 addr := address(parts[0])
360 role := parts[1]
361 assertRole(role)
362 for i, m := range members {
363 if m.Address == addr {
364 for _, r := range m.Roles {
365 if r == role {
366 panic("role already assigned")
367 }
368 }
369 members[i].Roles = append(members[i].Roles, role)
370 return
371 }
372 }
373 panic("member not found")
374}
375
376// ── Role Management (admin-only) ──────────────────────────
377
378func AssignRole(cur realm, target address, role string) {
379 caller := runtime.PreviousRealm().Address()
380 assertAdmin(caller)
381 assertRole(role)
382 for i, m := range members {
383 if m.Address == target {
384 // Check role not already assigned
385 for _, r := range m.Roles {
386 if r == role {
387 panic("role already assigned")
388 }
389 }
390 members[i].Roles = append(members[i].Roles, role)
391 return
392 }
393 }
394 panic("target is not a member")
395}
396
397func RemoveRole(cur realm, target address, role string) {
398 caller := runtime.PreviousRealm().Address()
399 assertAdmin(caller)
400 // Prevent removing last admin
401 if role == "admin" {
402 adminCount := 0
403 for _, m := range members {
404 if hasRoleInternal(m, "admin") {
405 adminCount++
406 }
407 }
408 if adminCount <= 1 {
409 panic("cannot remove the last admin")
410 }
411 }
412 for i, m := range members {
413 if m.Address == target {
414 newRoles := []string{}
415 for _, r := range m.Roles {
416 if r != role {
417 newRoles = append(newRoles, r)
418 }
419 }
420 members[i].Roles = newRoles
421 return
422 }
423 }
424 panic("target is not a member")
425}
426
427// ── Archive Management ────────────────────────────────────
428
429func Archive(cur realm) {
430 caller := runtime.PreviousRealm().Address()
431 assertAdmin(caller)
432 archived = true
433}
434
435func IsArchived() bool {
436 return archived
437}
438
439// ── Helpers ───────────────────────────────────────────────
440
441func assertNotArchived() {
442 if archived {
443 panic("DAO is archived — no new proposals or votes")
444 }
445}
446
447func assertMember(addr address) {
448 for _, m := range members {
449 if m.Address == addr {
450 return
451 }
452 }
453 panic("not a member")
454}
455
456func assertAdmin(addr address) {
457 for _, m := range members {
458 if m.Address == addr {
459 for _, r := range m.Roles {
460 if r == "admin" {
461 return
462 }
463 }
464 }
465 }
466 panic("admin role required")
467}
468
469func hasRole(addr address, role string) bool {
470 for _, m := range members {
471 if m.Address == addr {
472 return hasRoleInternal(m, role)
473 }
474 }
475 return false
476}
477
478func hasRoleInternal(m Member, role string) bool {
479 for _, r := range m.Roles {
480 if r == role {
481 return true
482 }
483 }
484 return false
485}
486
487func getMemberPower(addr address) int {
488 for _, m := range members {
489 if m.Address == addr {
490 return m.Power
491 }
492 }
493 return 0
494}
495
496func totalPower() int {
497 total := 0
498 for _, m := range members {
499 total += m.Power
500 }
501 return total
502}
503
504func assertCategory(cat string) {
505 for _, c := range allowedCategories {
506 if c == cat {
507 return
508 }
509 }
510 panic("invalid proposal category: " + cat)
511}
512
513func assertRole(role string) {
514 for _, r := range allowedRoles {
515 if r == role {
516 return
517 }
518 }
519 panic("invalid role: " + role)
520}
521
522// ── Config (for Memba integration) ────────────────────────
523
524func GetDAOConfig() string {
525 return name
526}