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}