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