Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}