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

policy.gno

8.48 Kb · 297 lines
  1// Package memba_weighted_policy implements the approved founding coalition and delay
  2// rules. The hosting DAO must authenticate callers, classify and freeze typed
  3// actions, and keep every Policy and Proposal private. This package grants no
  4// authority over a realm and is not a replacement for those integration gates.
  5package memba_weighted_policy
  6
  7import (
  8	"strings"
  9	"time"
 10)
 11
 12type Category string
 13
 14const (
 15	Routine   Category = "routine"
 16	Financial Category = "financial"
 17	Critical  Category = "critical"
 18)
 19
 20// VotingPeriod is fixed by the approved founding policy, not proposer input.
 21const VotingPeriod = 7 * 24 * time.Hour
 22
 23type Vote string
 24
 25const (
 26	Yes     Vote = "yes"
 27	No      Vote = "no"
 28	Abstain Vote = "abstain"
 29)
 30
 31// PersonID is the directory's unique human identity, never a wallet alias.
 32// The host must establish that different PersonIDs really are different people.
 33type Member struct {
 34	PersonID string
 35	Address  address
 36	Founder  bool
 37}
 38
 39// Policy fixes one founding seven-person roster. Reconfiguration deliberately
 40// invalidates every pending proposal, including already mature proposals.
 41// Only a reviewed critical-action handler in the host may call Reconfigure.
 42type Policy struct {
 43	members    [7]Member
 44	generation uint64
 45}
 46
 47func New(members []Member) *Policy {
 48	return &Policy{members: validateRoster(members), generation: 1}
 49}
 50
 51func validateRoster(members []Member) [7]Member {
 52	if len(members) != 7 {
 53		panic("policy requires seven people")
 54	}
 55	var roster [7]Member
 56	founders := 0
 57	for i, m := range members {
 58		if m.PersonID == "" || strings.TrimSpace(m.PersonID) != m.PersonID || !m.Address.IsValid() {
 59			panic("invalid member identity")
 60		}
 61		for j := 0; j < i; j++ {
 62			if roster[j].PersonID == m.PersonID || roster[j].Address == m.Address {
 63				panic("duplicate person or address")
 64			}
 65		}
 66		if m.Founder {
 67			founders++
 68		}
 69		roster[i] = m
 70	}
 71	if founders != 1 {
 72		panic("policy requires one founder and six developers")
 73	}
 74	return roster
 75}
 76
 77// Reconfigure is a host-only operation, not an authenticated public DAO API.
 78// It preserves the approved seven seats and 2/1 weights. Invalid input cannot
 79// mutate the roster or invalidate proposals, even if the host recovers a panic.
 80func (p *Policy) Reconfigure(members []Member) {
 81	roster := validateRoster(members)
 82	if p.generation == ^uint64(0) {
 83		panic("policy generation exhausted")
 84	}
 85	p.members = roster
 86	p.generation++
 87}
 88
 89func (p *Policy) memberIndex(who address) int {
 90	if p.generation == 0 {
 91		return -1
 92	}
 93	for i, m := range p.members {
 94		if m.Address == who {
 95			return i
 96		}
 97	}
 98	return -1
 99}
100
101// MayPause establishes member eligibility only. The host must dispatch only a
102// typed, allowlisted pause operation; this is never spend or unpause authority.
103func (p *Policy) MayPause(who address) bool { return p.memberIndex(who) >= 0 }
104
105// Proposal freezes the category, exact action bytes and roster generation.
106// The host determines the category from the action handler, never user input.
107// Each proposal has its own vote storage and qualification clocks.
108// yesSince[i] is the start of seat i's current uninterrupted YES ballot and is
109// meaningful only while votes[i] is Yes.
110type Proposal struct {
111	policy         *Policy
112	generation     uint64
113	category       Category
114	action         string
115	votes          [7]Vote
116	yesSince       [7]time.Time
117	createdAt      time.Time
118	votingDeadline time.Time
119	lastChange     time.Time
120	consumed       bool
121}
122
123func (p *Policy) NewProposal(category Category, action string) *Proposal {
124	if p.generation == 0 {
125		panic("uninitialized policy")
126	}
127	if category != Routine && category != Financial && category != Critical {
128		panic("unknown action category")
129	}
130	if action == "" {
131		panic("empty action")
132	}
133	now := time.Now()
134	return &Proposal{policy: p, generation: p.generation, category: category, action: action, createdAt: now, votingDeadline: now.Add(VotingPeriod), lastChange: now}
135}
136
137func (p *Proposal) assertActive() {
138	if p.consumed || p.generation != p.policy.generation {
139		panic("proposal consumed or invalidated")
140	}
141}
142
143// Vote requires a caller identity already authenticated by the host realm.
144// Voting closes at the deadline, including changes to existing ballots.
145// A critical route starts when some coalition that is still voting YES without
146// interruption first met its threshold. A withdrawal moves the clock only when
147// every such coalition needed that ballot, so voters the rest of the YES set
148// does not need cannot postpone execution by withdrawing and rejoining.
149func (p *Proposal) Vote(who address, vote Vote) {
150	p.assertActive()
151	i := p.policy.memberIndex(who)
152	if i < 0 {
153		panic("voter is not a member")
154	}
155	if vote != Yes && vote != No && vote != Abstain {
156		panic("invalid vote")
157	}
158	now := time.Now()
159	if now.Before(p.lastChange) {
160		panic("chain time regressed")
161	}
162	if !now.Before(p.votingDeadline) {
163		panic("voting closed")
164	}
165	if p.votes[i] == vote {
166		return
167	}
168	p.votes[i] = vote
169	p.lastChange = now
170	if vote == Yes {
171		p.yesSince[i] = now
172	}
173}
174
175// qualifiedSince returns the earliest start of an uninterrupted YES coalition
176// meeting the weighted (6 points, 4 people) or developer (5 developers)
177// critical threshold, or the zero time when no current coalition qualifies.
178// Candidate starts are the seats' own streak starts; the coalition for a
179// candidate is every current YES ballot that began no later than it.
180func (p *Proposal) qualifiedSince(developersOnly bool) time.Time {
181	var earliest time.Time
182	for c, candidate := range p.votes {
183		if candidate != Yes {
184			continue
185		}
186		start := p.yesSince[c]
187		if !earliest.IsZero() && !start.Before(earliest) {
188			continue
189		}
190		weight, people := 0, 0
191		for j, vote := range p.votes {
192			if vote != Yes || p.yesSince[j].After(start) {
193				continue
194			}
195			founder := p.policy.members[j].Founder
196			if developersOnly && founder {
197				continue
198			}
199			people++
200			weight++
201			if founder {
202				weight++
203			}
204		}
205		if developersOnly && people >= 5 || !developersOnly && weight >= 6 && people >= 4 {
206			earliest = start
207		}
208	}
209	return earliest
210}
211
212func (p *Proposal) tally() (weight, people, developers int) {
213	for i, vote := range p.votes {
214		if vote != Yes {
215			continue
216		}
217		people++
218		weight++
219		if p.policy.members[i].Founder {
220			weight++
221		} else {
222			developers++
223		}
224	}
225	return
226}
227
228// State is a copy for rendering. Reading never starts or advances a clock.
229// An invalidated proposal reports no current tally or execution authorization.
230type State struct {
231	Category       Category
232	CreatedAt      time.Time
233	VotingDeadline time.Time
234	VotingClosed   bool
235	Qualified      bool
236	Expired        bool
237	WeightYes      int
238	PeopleYes      int
239	DevelopersYes  int
240	WeightedAfter  time.Time
241	DeveloperAfter time.Time
242	Invalidated    bool
243	Consumed       bool
244	Ready          bool
245}
246
247func (p *Proposal) State() State {
248	now := time.Now()
249	s := State{Category: p.category, CreatedAt: p.createdAt, VotingDeadline: p.votingDeadline, VotingClosed: !now.Before(p.votingDeadline), Invalidated: p.generation != p.policy.generation, Consumed: p.consumed}
250	if s.Invalidated || s.Consumed {
251		return s
252	}
253	s.WeightYes, s.PeopleYes, s.DevelopersYes = p.tally()
254	if now.Before(p.lastChange) {
255		return s
256	}
257	switch p.category {
258	case Routine:
259		s.Qualified = s.WeightYes >= 3 && s.PeopleYes >= 2
260		s.Ready = s.Qualified
261	case Financial:
262		s.Qualified = s.WeightYes >= 5 && s.PeopleYes >= 3
263		s.Ready = s.Qualified
264	case Critical:
265		weightedSince := p.qualifiedSince(false)
266		developerSince := p.qualifiedSince(true)
267		s.Qualified = !weightedSince.IsZero() || !developerSince.IsZero()
268		if !weightedSince.IsZero() {
269			s.WeightedAfter = weightedSince.Add(24 * time.Hour)
270			s.Ready = !now.Before(s.WeightedAfter)
271		}
272		if !developerSince.IsZero() {
273			s.DeveloperAfter = developerSince.Add(72 * time.Hour)
274			s.Ready = s.Ready || !now.Before(s.DeveloperAfter)
275		}
276	}
277	// Ballots are immutable after closing. A pre-deadline qualification can
278	// finish its existing delay afterward; an unqualified ballot cannot revive.
279	s.Expired = s.VotingClosed && !s.Qualified
280	return s
281}
282
283// Consume binds execution to the frozen action and marks it consumed BEFORE
284// the host invokes its typed handler. The host must let handler failure abort
285// the transaction: recovering a panic does not roll back Gno realm state.
286// Caller authentication and current-member executor eligibility belong to the
287// host. Do not expose Proposal pointers or call Consume from a read endpoint.
288func (p *Proposal) Consume(action string) {
289	p.assertActive()
290	if action != p.action {
291		panic("action changed")
292	}
293	if !p.State().Ready {
294		panic("proposal is not ready")
295	}
296	p.consumed = true
297}