Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}