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}