Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}