Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}