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