Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

gov.gno

15.56 Kb · 543 lines
  1// Package memba_gov is the Memba DAO governance core. It holds the roster,
  2// proposals and votes, and grants nothing by itself: a governed realm asks
  3// for an approval by crossing-calling Consume before a privileged step, and
  4// the core checks that the caller is exactly the realm the proposal names,
  5// with exactly the arguments members voted on. The core never calls another
  6// realm.
  7//
  8// Rules (design note GOV-1, approved 2026-10-06):
  9//   - members propose, vote and execute by direct calls from their own
 10//     account, and an invited key joins by its own direct call; only a
 11//     governed realm's crossing call reaches Consume;
 12//   - a key counts toward the roster only after it signed Join itself;
 13//   - one action per proposal, stored by value; approvals are used once;
 14//   - every executed roster action and every Join invalidates every open
 15//     proposal, so votes always count against an unchanged roster;
 16//   - Consume invalidates the other open proposals of its (target, scope).
 17package memba_gov
 18
 19import (
 20	"strconv"
 21	"strings"
 22	"time"
 23
 24	"gno.land/p/samcrew/avl"
 25	"gno.land/p/samcrew/daoauth"
 26)
 27
 28const (
 29	corePath  = "gno.land/r/samcrew/memba_gov"
 30	MaxSeats  = 25
 31	MinSeats  = 2
 32	MaxScope  = 160
 33	MaxNote   = 280
 34	MaxAction = 64
 35
 36	actAddMember      = "AddMember"
 37	actRemoveMember   = "RemoveMember"
 38	actSetWeight      = "SetWeight"
 39	actRecover        = "Recover"
 40	actRemoveInactive = "RemoveInactive"
 41	actUninvite       = "Uninvite"
 42
 43	voteYes     = "yes"
 44	voteNo      = "no"
 45	voteAbstain = "abstain"
 46)
 47
 48type person struct {
 49	id         string
 50	addr       address
 51	weight     int
 52	lastActive int64  // unix seconds of the last Propose, Vote, Execute or Join
 53	rosterPid  uint64 // this person's open roster proposal, if any
 54}
 55
 56// invite seats addr as id (a new person) or, for a recovery, gives the
 57// existing person id the new key addr, once addr itself calls Join.
 58type invite struct {
 59	id      string
 60	addr    address
 61	weight  int
 62	expires int64 // unix seconds
 63}
 64
 65type ballot struct {
 66	person string
 67	vote   string
 68	since  int64 // unix start of the current uninterrupted YES streak
 69}
 70
 71type proposal struct {
 72	id                    uint64
 73	target, action, args  string
 74	scope, note, proposer string
 75	subject               string // the person a roster action names
 76	class                 int
 77	scopeGen, globalGen   uint64
 78	created               int64 // unix seconds
 79	ballots               []ballot
 80	consumed              bool
 81}
 82
 83var (
 84	seated    []*person
 85	invites   []*invite
 86	proposals = avl.NewTree() // padded id -> *proposal
 87	scopes    = avl.NewTree() // target + "\n" + scope -> uint64 generation
 88	lastID    uint64
 89	globalGen uint64
 90)
 91
 92func init() { seedRoster() }
 93
 94func seedRoster() {
 95	t := now()
 96	for _, s := range seedSeated {
 97		seated = append(seated, &person{id: s.id, addr: s.addr, weight: s.weight, lastActive: t})
 98	}
 99	for _, s := range seedInvited {
100		invites = append(invites, &invite{id: s.id, addr: s.addr, weight: s.weight, expires: t + secs(SeedInviteTTL)})
101	}
102	checkRoster()
103}
104
105// now is the block time in unix seconds; every stored time uses it.
106func now() int64 { return time.Now().Unix() }
107
108func secs(d time.Duration) int64 { return int64(d / time.Second) }
109
110// ── Writes (direct calls from a member's account) ──────────────────────
111
112// Propose files one action. target is the realm that will consume it, or
113// this realm for a roster action; scope is the invalidation key that realm
114// computes for the action; class is at least the class it requires.
115func Propose(cur realm, target, action, args, scope string, class int, note string) uint64 {
116	m := member(cur)
117	if !validTarget(target) || !validAction(action) || len(scope) > MaxScope || len(note) > MaxNote ||
118		!daoauth.ValidText(scope) || !daoauth.ValidText(note) {
119		panic("memba_gov: invalid target, action, scope or note")
120	}
121	fields, err := daoauth.Parse(args)
122	if err != nil {
123		panic(err.Error())
124	}
125	if target == corePath {
126		if scope != "" || class != rosterClass(action) {
127			panic("memba_gov: a roster action has no scope and a fixed class")
128		}
129		checkRosterAction(action, fields)
130		if m.rosterPid != 0 && isOpen(m.rosterPid) {
131			panic("memba_gov: you already have an open roster proposal")
132		}
133	} else if class < Routine || class > Critical {
134		panic("memba_gov: unknown class")
135	}
136	lastID++
137	p := &proposal{id: lastID, target: target, action: action, args: args, scope: scope, note: note,
138		proposer: m.id, class: class, scopeGen: scopeGen(target, scope), globalGen: globalGen, created: now()}
139	proposals.Set(key(p.id), p)
140	if target == corePath {
141		p.subject = fields[0].Value
142		m.rosterPid = p.id
143	}
144	m.lastActive = p.created
145	return p.id
146}
147
148// Vote records yes, no or abstain until the voting deadline. After it, a
149// YES can still be withdrawn while the proposal is not executed or expired,
150// so members can stop an approval they no longer back.
151func Vote(cur realm, pid uint64, choice string) {
152	m := member(cur)
153	if choice != voteYes && choice != voteNo && choice != voteAbstain {
154		panic("memba_gov: vote yes, no or abstain")
155	}
156	p := mustGet(pid)
157	t := now()
158	mine := -1
159	for i, b := range p.ballots {
160		if b.person == m.id {
161			mine = i
162		}
163	}
164	st, _ := state(p)
165	open := st == stVoting || st == stTimelocked || st == stReady
166	withdrawing := mine >= 0 && p.ballots[mine].vote == voteYes && choice != voteYes
167	if !open || (t >= p.created+secs(VotingPeriod) && !withdrawing) {
168		panic("memba_gov: voting is closed")
169	}
170	m.lastActive = t
171	switch {
172	case mine < 0:
173		p.ballots = append(p.ballots, ballot{person: m.id, vote: choice, since: t})
174	case p.ballots[mine].vote != choice:
175		p.ballots[mine] = ballot{person: m.id, vote: choice, since: t}
176	}
177}
178
179// Join seats the caller's invitation: a new person, or a recovered key.
180func Join(cur realm) {
181	addr := user(cur)
182	t := now()
183	for i, inv := range invites {
184		if inv.addr != addr || t > inv.expires {
185			continue
186		}
187		invites = append(invites[:i:i], invites[i+1:]...)
188		if p := personByID(inv.id); p != nil {
189			p.addr = addr
190			p.lastActive = t
191		} else {
192			if len(seated) >= MaxSeats {
193				panic("memba_gov: the roster is full (" + strconv.Itoa(MaxSeats) + " seats)")
194			}
195			seated = append(seated, &person{id: inv.id, addr: addr, weight: inv.weight, lastActive: t})
196		}
197		globalGen++
198		checkRoster()
199		return
200	}
201	panic("memba_gov: no open invitation for " + string(addr))
202}
203
204// Execute runs a ready roster action of this realm. The roster it was
205// checked against at Propose is unchanged: any change would have
206// invalidated it.
207func Execute(cur realm, pid uint64) {
208	m := member(cur)
209	p := mustGet(pid)
210	if p.target != corePath {
211		panic("memba_gov: only roster actions run here; the target realm consumes the others")
212	}
213	if st, _ := state(p); st != stReady {
214		panic("memba_gov: proposal is " + st)
215	}
216	fields, _ := daoauth.Parse(p.args)
217	id := p.subject
218	switch p.action {
219	case actAddMember:
220		addInvite(id, address(fields[1].Value), weightField(fields[2]), VotedInviteTTL)
221	case actRecover:
222		addInvite(id, address(fields[1].Value), 0, VotedInviteTTL)
223	case actSetWeight:
224		personByID(id).weight = weightField(fields[1])
225	case actRemoveMember, actRemoveInactive, actUninvite:
226		removePerson(id)
227	}
228	p.consumed = true
229	m.lastActive = now()
230	globalGen++
231	checkRoster()
232}
233
234// ── The only cross-realm entry ──────────────────────────────────────────
235
236// Consume is crossing-called by a governed realm right before the step a
237// proposal approved. It panics unless the caller is exactly the proposal's
238// target, the action, arguments and scope are the voted ones byte for byte,
239// the class is at least minClass, the proposal is ready, and executor, the
240// account that called the target directly, is a seated member. It marks the
241// proposal used and invalidates the other open proposals of (target, scope).
242func Consume(cur realm, pid uint64, action, args, scope string, minClass int, executor address) {
243	if !cur.IsCurrent() {
244		panic("memba_gov: stale realm")
245	}
246	caller := cur.Previous().PkgPath()
247	p := mustGet(pid)
248	// A roster action runs only through Execute.
249	if caller != p.target || p.target == corePath {
250		panic("memba_gov: proposal " + strconv.FormatUint(pid, 10) + " is not for " + caller)
251	}
252	if action != p.action || args != p.args || scope != p.scope {
253		panic("memba_gov: action, arguments or scope differ from the vote")
254	}
255	if p.class < minClass {
256		panic("memba_gov: class below what the target requires")
257	}
258	if st, _ := state(p); st != stReady {
259		panic("memba_gov: proposal is " + st)
260	}
261	// Being named executor is not activity: the target, not the member,
262	// supplies that address.
263	if personByAddr(executor) == nil {
264		panic("memba_gov: executor is not a seated member")
265	}
266	p.consumed = true
267	scopes.Set(p.target+"\n"+p.scope, scopeGen(p.target, p.scope)+1)
268}
269
270// IsMember reports whether addr is a seated member's current key.
271func IsMember(addr address) bool { return personByAddr(addr) != nil }
272
273// PersonOf returns the id of the seated member whose current key is addr,
274// or "". The id survives a key recovery.
275func PersonOf(addr address) string {
276	if p := personByAddr(addr); p != nil {
277		return p.id
278	}
279	return ""
280}
281
282// ── Helpers ─────────────────────────────────────────────────────────────
283
284// user returns the account that called this realm directly.
285func user(cur realm) address {
286	if !cur.IsCurrent() {
287		panic("memba_gov: stale realm")
288	}
289	prev := cur.Previous()
290	if !prev.IsUserCall() {
291		panic("memba_gov: call directly from your account")
292	}
293	return prev.Address()
294}
295
296func member(cur realm) *person {
297	addr := user(cur)
298	m := personByAddr(addr)
299	if m == nil {
300		panic("memba_gov: not a seated member: " + string(addr))
301	}
302	return m
303}
304
305// rosterClass and rosterDelay fix how each roster action passes.
306func rosterClass(action string) int {
307	switch action {
308	case actRemoveInactive:
309		return Routine
310	case actUninvite: // fast for a leaked invitee key, never a two-person lever
311		return Financial
312	}
313	return Critical
314}
315
316func rosterDelay(action string) time.Duration {
317	switch action {
318	case actUninvite:
319		return 0
320	case actRemoveInactive:
321		return InactiveDelay
322	}
323	return RosterDelay
324}
325
326// checkRosterAction validates a roster action's fields against the roster.
327func checkRosterAction(action string, f []daoauth.Field) {
328	shape := map[string]string{actAddMember: "sau", actRemoveMember: "s", actSetWeight: "su",
329		actRecover: "sa", actRemoveInactive: "s", actUninvite: "s"}[action]
330	if shape == "" || len(f) != len(shape) {
331		panic("memba_gov: unknown roster action or wrong arguments")
332	}
333	for i := range f {
334		if f[i].Tag != shape[i] {
335			panic("memba_gov: wrong roster arguments")
336		}
337	}
338	id := f[0].Value
339	p := personByID(id)
340	switch action {
341	case actAddMember, actUninvite:
342		if p != nil || !validID(id) {
343			panic("memba_gov: person id taken or invalid")
344		}
345	default:
346		if p == nil {
347			panic("memba_gov: no seated person " + id)
348		}
349	}
350	switch action {
351	case actAddMember, actRecover:
352		if addrTaken(address(f[1].Value), id) {
353			panic("memba_gov: address already seated or invited")
354		}
355	case actUninvite:
356		if !invited(id) {
357			panic("memba_gov: no open invitation for " + id)
358		}
359	case actRemoveMember, actRemoveInactive:
360		if len(seated) <= MinSeats {
361			panic("memba_gov: the roster cannot shrink below " + strconv.Itoa(MinSeats))
362		}
363	}
364	if action == actAddMember {
365		weightField(f[2])
366	}
367	if action == actSetWeight && weightField(f[1]) == p.weight {
368		panic("memba_gov: weight unchanged")
369	}
370	if action == actRemoveInactive && now() <= p.lastActive+secs(InactiveAfter) {
371		panic("memba_gov: active within " + strconv.Itoa(int(secs(InactiveAfter)/86400)) + " days")
372	}
373}
374
375func weightField(f daoauth.Field) int {
376	if f.Value != "1" && f.Value != "2" {
377		panic("memba_gov: weight is 1 or 2")
378	}
379	return int(f.Value[0] - '0')
380}
381
382func addInvite(id string, addr address, weight int, ttl time.Duration) {
383	dropInvite(id)
384	invites = append(invites, &invite{id: id, addr: addr, weight: weight, expires: now() + secs(ttl)})
385}
386
387func dropInvite(id string) {
388	kept := invites[:0:0]
389	for _, inv := range invites {
390		if inv.id != id {
391			kept = append(kept, inv)
392		}
393	}
394	invites = kept
395}
396
397func removePerson(id string) {
398	kept := seated[:0:0]
399	for _, p := range seated {
400		if p.id != id {
401			kept = append(kept, p)
402		}
403	}
404	seated = kept
405	dropInvite(id)
406}
407
408// checkRoster enforces the bounds, valid ids, addresses and weights, and the
409// uniqueness of ids and of addresses across seats and open invitations. An
410// invitation may name a seated id: that is a recovery, with no weight.
411func checkRoster() {
412	if len(seated) < MinSeats || len(seated) > MaxSeats {
413		panic("memba_gov: roster size out of bounds")
414	}
415	seen := map[string]bool{}
416	for _, p := range seated {
417		if seen["i"+p.id] || seen["a"+string(p.addr)] || !validID(p.id) || !daoauth.ValidAddress(p.addr) || p.weight < 1 || p.weight > 2 {
418			panic("memba_gov: invalid roster")
419		}
420		seen["i"+p.id], seen["a"+string(p.addr)] = true, true
421	}
422	t := now()
423	for _, inv := range invites {
424		if t > inv.expires {
425			continue
426		}
427		recovery := personByID(inv.id) != nil
428		if seen["v"+inv.id] || seen["a"+string(inv.addr)] || !validID(inv.id) || !daoauth.ValidAddress(inv.addr) ||
429			(!recovery && (inv.weight < 1 || inv.weight > 2)) {
430			panic("memba_gov: invalid invitation")
431		}
432		seen["v"+inv.id], seen["a"+string(inv.addr)] = true, true
433	}
434}
435
436// addrTaken reports whether a is seated, or invited for someone other than
437// id: a newer invitation for id replaces id's own.
438func addrTaken(a address, id string) bool {
439	if personByAddr(a) != nil {
440		return true
441	}
442	for _, inv := range invites {
443		if inv.addr == a && inv.id != id && now() <= inv.expires {
444			return true
445		}
446	}
447	return false
448}
449
450func invited(id string) bool {
451	for _, inv := range invites {
452		if inv.id == id && now() <= inv.expires {
453			return true
454		}
455	}
456	return false
457}
458
459func personByAddr(a address) *person {
460	for _, p := range seated {
461		if p.addr == a {
462			return p
463		}
464	}
465	return nil
466}
467
468func personByID(id string) *person {
469	for _, p := range seated {
470		if p.id == id {
471			return p
472		}
473	}
474	return nil
475}
476
477func weightOf(id string) int {
478	if p := personByID(id); p != nil {
479		return p.weight
480	}
481	return 0
482}
483
484func seatedTotals() (w, n int) {
485	for _, p := range seated {
486		w += p.weight
487	}
488	return w, len(seated)
489}
490
491func scopeGen(target, scope string) uint64 {
492	if v, ok := scopes.Get(target + "\n" + scope); ok {
493		return v.(uint64)
494	}
495	return 0
496}
497
498func isOpen(pid uint64) bool {
499	st, _ := state(mustGet(pid))
500	return st == stVoting || st == stTimelocked || st == stReady
501}
502
503func mustGet(pid uint64) *proposal {
504	v, ok := proposals.Get(key(pid))
505	if !ok {
506		panic("memba_gov: no proposal " + strconv.FormatUint(pid, 10))
507	}
508	return v.(*proposal)
509}
510
511func key(id uint64) string {
512	s := strconv.FormatUint(id, 10)
513	return strings.Repeat("0", 20-len(s)) + s
514}
515
516// validTarget accepts a realm path: no user ("") or MsgRun (gno.land/e/)
517// identity, no sub-realm ("#"), lowercase path characters only.
518func validTarget(t string) bool {
519	return strings.HasPrefix(t, "gno.land/r/") && len(t) <= 256 && !strings.Contains(t, "//") &&
520		!strings.HasSuffix(t, "/") && only(t, lower+digits+"_/.")
521}
522
523func validAction(a string) bool {
524	return a != "" && len(a) <= MaxAction && only(a, lower+upper+digits+"_.")
525}
526
527func validID(id string) bool { return id != "" && len(id) <= 32 && only(id, lower+digits+"_-") }
528
529const (
530	lower  = "abcdefghijklmnopqrstuvwxyz"
531	upper  = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
532	digits = "0123456789"
533)
534
535// only reports whether every byte of s is in set.
536func only(s, set string) bool {
537	for i := 0; i < len(s); i++ {
538		if strings.IndexByte(set, s[i]) < 0 {
539			return false
540		}
541	}
542	return true
543}