host.gno
14.56 Kb · 477 lines
1// Package memba_weighted_host hosts the founding policy's governed role actions.
2// This source-only component is not the current memba_dao and grants no external
3// application authority. The owning realm must keep its Host private.
4package memba_weighted_host
5
6import (
7 "chain/runtime"
8 "strconv"
9 "strings"
10 "time"
11
12 "gno.land/p/samcrew/memba_weighted_policy"
13)
14
15type Role string
16
17const (
18 Admin Role = "admin"
19 Finance Role = "finance"
20)
21
22// Seat is copied at construction. Founder/core-developer voting eligibility is
23// independent from these operational role labels; all members may execute.
24type Seat struct {
25 Member memba_weighted_policy.Member
26 Admin bool
27 Finance bool
28}
29
30type roleProposal struct {
31 feedback *feedbackProposal
32 channels *channelsProposal
33 feed *feedProposal
34 badges *badgesProposal
35 escrow *escrowProposal
36 appstore *appstoreProposal
37 arcade *arcadeProposal
38 quest *questProposal
39 reviews *reviewsProposal
40 market *marketProposal
41 recovery bool
42 personID string
43 replacement address
44 proposer address
45 target address
46 role Role
47 grant bool
48 ballot *memba_weighted_policy.Proposal
49 // roster indexes Host.rosters: the electorate frozen at creation. choices
50 // and votedAt mirror each seat's current ballot and the height it was cast;
51 // they are written only by Host.Vote, after the policy accepted the vote.
52 roster int
53 choices [7]memba_weighted_policy.Vote
54 votedAt [7]int64
55 // epoch is len(Host.invalidations) at creation: the first reconfiguration
56 // recorded after it is the one that invalidated this proposal.
57 epoch int
58}
59
60// Invalidation causes recorded on every policy reconfiguration.
61const (
62 InvalidatedByExecution = "superseded-execution"
63 InvalidatedByPause = "pause"
64)
65
66// invalidation records one policy reconfiguration: every proposal open at that
67// moment, other than the one executed, stopped accepting votes and execution.
68type invalidation struct {
69 height int64
70 at time.Time
71 cause string
72 proposal uint64
73 target string
74}
75
76type Host struct {
77 feedbackPolicy *FeedbackPolicy
78 channelsPolicy *ChannelsPolicy
79 feedPolicy *FeedPolicy
80 badgesPolicy *BadgesPolicy
81 escrowPolicy *EscrowPolicy
82 appstorePolicy *AppstorePolicy
83 arcadePolicy *ArcadePolicy
84 questPolicy *QuestPolicy
85 reviewsPolicy *ReviewsPolicy
86 marketPolicy *MarketPolicy
87 path string
88 seats [7]Seat
89 policy *memba_weighted_policy.Policy
90 proposals []*roleProposal
91 // rosters holds every distinct electorate, oldest first. A new entry is
92 // appended only when a recovery changes a seat address.
93 rosters [][7]address
94 // invalidations mirrors the policy generation: one record per Reconfigure.
95 invalidations []invalidation
96}
97
98// New must run inside the owning realm's init with its own live realm value.
99// No realm value is persisted; each mutation requires a fresh live frame.
100func New(seats []Seat, rlm realm) *Host {
101 if !rlm.IsCurrent() || !strings.HasPrefix(rlm.PkgPath(), "gno.land/r/") {
102 panic("host requires its owning live realm")
103 }
104 members := []memba_weighted_policy.Member{}
105 admins := 0
106 for _, seat := range seats {
107 members = append(members, seat.Member)
108 if seat.Admin {
109 admins++
110 }
111 }
112 policy := memba_weighted_policy.New(members)
113 if admins == 0 {
114 panic("host requires an admin")
115 }
116 h := &Host{path: rlm.PkgPath(), policy: policy}
117 for i, seat := range seats {
118 h.seats[i] = seat
119 }
120 h.snapshotRoster()
121 return h
122}
123
124func (h *Host) snapshotRoster() {
125 var roster [7]address
126 for i, seat := range h.seats {
127 roster[i] = seat.Member.Address
128 }
129 h.rosters = append(h.rosters, roster)
130}
131
132// addProposal freezes the current electorate on the proposal and stores it.
133// Every Propose* path goes through here, so ballots always have a snapshot.
134func (h *Host) addProposal(p *roleProposal) {
135 p.roster = len(h.rosters) - 1
136 p.epoch = len(h.invalidations)
137 h.proposals = append(h.proposals, p)
138}
139
140// recordInvalidation must follow every policy Reconfigure. It records why the
141// open proposals stopped; it never decides which ones stop.
142func (h *Host) recordInvalidation(cause string, id uint64, target string) {
143 h.invalidations = append(h.invalidations, invalidation{height: runtime.ChainHeight(), at: time.Now(), cause: cause, proposal: id, target: target})
144}
145
146// ballotsQualified recomputes the policy threshold from the mirrored ballots.
147// Ballots cannot change after a proposal closes or is invalidated, so this is
148// the qualification the proposal had at that moment.
149func (h *Host) ballotsQualified(p *roleProposal, category memba_weighted_policy.Category) bool {
150 weight, people, developers := 0, 0, 0
151 for i, vote := range p.choices {
152 if vote != memba_weighted_policy.Yes {
153 continue
154 }
155 people++
156 weight++
157 if h.seats[i].Member.Founder {
158 weight++
159 } else {
160 developers++
161 }
162 }
163 switch category {
164 case memba_weighted_policy.Routine:
165 return weight >= 3 && people >= 2
166 case memba_weighted_policy.Financial:
167 return weight >= 5 && people >= 3
168 default:
169 return weight >= 6 && people >= 4 || developers >= 5
170 }
171}
172
173func (h *Host) memberIndex(who address) int {
174 for i, seat := range h.seats {
175 if seat.Member.Address == who {
176 return i
177 }
178 }
179 return -1
180}
181
182// Require both the owning path and its currently executing frame. A callee
183// holding a previous DAO frame may not donate it back for authentication.
184func assertCaller(h *Host, rlm realm) address {
185 if rlm.PkgPath() != h.path || !rlm.IsCurrent() {
186 panic("host realm mismatch")
187 }
188 who := rlm.Previous().Address()
189 if !who.IsValid() || h.memberIndex(who) < 0 {
190 panic("caller is not a member")
191 }
192 return who
193}
194
195func (h *Host) HasRole(who address, role Role) bool {
196 i := h.memberIndex(who)
197 if i < 0 {
198 return false
199 }
200 switch role {
201 case Admin:
202 return h.seats[i].Admin
203 case Finance:
204 return h.seats[i].Finance
205 default:
206 return false
207 }
208}
209
210func (h *Host) validateRoleChange(target address, role Role, grant bool) {
211 if h.memberIndex(target) < 0 {
212 panic("role target is not a member")
213 }
214 if role != Admin && role != Finance {
215 panic("unsupported role")
216 }
217 if h.HasRole(target, role) == grant {
218 panic("role change is a no-op")
219 }
220 if role == Admin && !grant {
221 admins := 0
222 for _, seat := range h.seats {
223 if seat.Admin {
224 admins++
225 }
226 }
227 if admins <= 1 {
228 panic("cannot remove last admin")
229 }
230 }
231}
232
233// Every role change is critical. The proposer cannot select a lower category,
234// inject a callback or substitute the target/role/grant after voting begins.
235func (h *Host) ProposeRole(target address, role Role, grant bool, rlm realm) uint64 {
236 who := assertCaller(h, rlm)
237 h.validateRoleChange(target, role, grant)
238 p := &roleProposal{proposer: who, target: target, role: role, grant: grant}
239 p.ballot = h.policy.NewProposal(memba_weighted_policy.Critical, actionBytes(p))
240 h.addProposal(p)
241 return uint64(len(h.proposals))
242}
243
244// Recovery replaces one person's key, never their identity, weight or roles.
245func (h *Host) validateRecovery(personID string, target, replacement address) {
246 i := h.memberIndex(target)
247 if i < 0 || h.seats[i].Member.PersonID != personID {
248 panic("recovery seat does not match")
249 }
250 if !replacement.IsValid() || h.memberIndex(replacement) >= 0 {
251 panic("replacement must be a valid unused address")
252 }
253}
254
255func (h *Host) ProposeRecovery(personID string, target, replacement address, rlm realm) uint64 {
256 who := assertCaller(h, rlm)
257 h.validateRecovery(personID, target, replacement)
258 p := &roleProposal{proposer: who, recovery: true, personID: personID, target: target, replacement: replacement}
259 p.ballot = h.policy.NewProposal(memba_weighted_policy.Critical, actionBytes(p))
260 h.addProposal(p)
261 return uint64(len(h.proposals))
262}
263
264func actionBytes(p *roleProposal) string {
265 if p.recovery {
266 // Length-prefix the unrestricted human ID to make the commitment unambiguous.
267 return "recover/v1|" + strconv.Itoa(len(p.personID)) + ":" + p.personID + "|" + string(p.target) + "|" + string(p.replacement)
268 }
269 // Address and role domains are validated and contain no delimiter.
270 return "role/v1|" + string(p.target) + "|" + string(p.role) + "|" + strconv.FormatBool(p.grant)
271}
272
273func (h *Host) proposal(id uint64) *roleProposal {
274 if id == 0 || id > uint64(len(h.proposals)) {
275 panic("proposal not found")
276 }
277 return h.proposals[id-1]
278}
279
280func (h *Host) Vote(id uint64, vote memba_weighted_policy.Vote, rlm realm) {
281 who := assertCaller(h, rlm)
282 p := h.proposal(id)
283 p.ballot.Vote(who, vote)
284 // The policy accepted the ballot, so the proposal is active and its frozen
285 // electorate is the current roster. A repeated identical ballot is a no-op
286 // in the policy and keeps its original height here.
287 i := rosterIndex(h.rosters[p.roster], who)
288 if i < 0 {
289 panic("voter is not in the proposal electorate")
290 }
291 if p.choices[i] != vote {
292 p.choices[i] = vote
293 p.votedAt[i] = runtime.ChainHeight()
294 }
295}
296
297func rosterIndex(roster [7]address, who address) int {
298 for i, a := range roster {
299 if a == who {
300 return i
301 }
302 }
303 return -1
304}
305
306func (h *Host) Execute(id uint64, rlm realm) {
307 assertCaller(h, rlm)
308 p := h.proposal(id)
309 if p.feedback != nil {
310 panic("feedback action requires target adapter")
311 }
312 if p.channels != nil {
313 panic("channels action requires target adapter")
314 }
315 if p.feed != nil {
316 panic("feed action requires target adapter")
317 }
318 if p.badges != nil {
319 panic("badges action requires target adapter")
320 }
321 if p.escrow != nil {
322 panic("escrow action requires target adapter")
323 }
324 if p.appstore != nil {
325 panic("appstore action requires target adapter")
326 }
327 if p.arcade != nil {
328 panic("arcade action requires target adapter")
329 }
330 if p.quest != nil {
331 panic("quest action requires target adapter")
332 }
333 if p.reviews != nil {
334 panic("reviews action requires target adapter")
335 }
336 if p.market != nil {
337 panic("market action requires target adapter")
338 }
339 if p.recovery {
340 h.validateRecovery(p.personID, p.target, p.replacement)
341 } else {
342 h.validateRoleChange(p.target, p.role, p.grant)
343 }
344 p.ballot.Consume(actionBytes(p))
345 i := h.memberIndex(p.target)
346 if p.recovery {
347 h.seats[i].Member.Address = p.replacement
348 h.snapshotRoster()
349 } else if p.role == Admin {
350 h.seats[i].Admin = p.grant
351 } else {
352 h.seats[i].Finance = p.grant
353 }
354 // Any changed authority invalidates all outstanding authorizations. Roles
355 // do not alter the approved weights or the independent developer route.
356 members := []memba_weighted_policy.Member{}
357 for _, seat := range h.seats {
358 members = append(members, seat.Member)
359 }
360 h.policy.Reconfigure(members)
361 h.recordInvalidation(InvalidatedByExecution, id, "")
362}
363
364// Views contain copied values only, never pointers to policy, ballots or seats.
365type ProposalView struct {
366 Feedback bool
367 FeedbackAction FeedbackAction
368 FeedbackBefore FeedbackState
369 Channels bool
370 ChannelsAction ChannelsAction
371 ChannelsBefore ChannelsState
372 Feed bool
373 FeedAction FeedAction
374 FeedBefore FeedState
375 Badges bool
376 BadgesAction BadgesAction
377 BadgesBefore BadgesState
378 Escrow bool
379 EscrowAction EscrowAction
380 EscrowBefore EscrowState
381 Appstore bool
382 AppstoreAction AppstoreAction
383 AppstoreBefore AppstoreState
384 Arcade bool
385 ArcadeAction ArcadeAction
386 ArcadeBefore ArcadeState
387 Quest bool
388 QuestAction QuestAction
389 QuestBefore QuestState
390 Reviews bool
391 ReviewsAction ReviewsAction
392 ReviewsBefore ReviewsState
393 Market bool
394 MarketAction MarketAction
395 MarketBefore MarketState
396 Recovery bool
397 PersonID string
398 Replacement address
399 ID uint64
400 Proposer address
401 Target address
402 Role Role
403 Grant bool
404 State memba_weighted_policy.State
405 // Invalidation is set only for an INVALIDATED proposal. A proposal whose
406 // vote had already closed without qualifying stays EXPIRED instead.
407 Invalidated bool
408 InvalidationCause string
409 InvalidationHeight int64
410 InvalidationProposal uint64
411 InvalidationTarget string
412 ExpiredBeforeInvalidation bool
413}
414
415func (h *Host) Proposal(id uint64) ProposalView {
416 v := h.proposalView(id)
417 p := h.proposal(id)
418 s := v.State
419 if s.Invalidated && !s.Consumed && p.epoch < len(h.invalidations) {
420 rec := h.invalidations[p.epoch]
421 if !rec.at.Before(s.VotingDeadline) && !h.ballotsQualified(p, s.Category) {
422 v.ExpiredBeforeInvalidation = true
423 } else {
424 v.Invalidated = true
425 v.InvalidationCause = rec.cause
426 v.InvalidationHeight = rec.height
427 v.InvalidationProposal = rec.proposal
428 v.InvalidationTarget = rec.target
429 }
430 }
431 return v
432}
433
434func (h *Host) proposalView(id uint64) ProposalView {
435 p := h.proposal(id)
436 if p.feedback != nil {
437 return ProposalView{Feedback: true, FeedbackAction: p.feedback.action, FeedbackBefore: p.feedback.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
438 }
439 if p.channels != nil {
440 return ProposalView{Channels: true, ChannelsAction: p.channels.action, ChannelsBefore: p.channels.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
441 }
442 if p.feed != nil {
443 return ProposalView{Feed: true, FeedAction: p.feed.action, FeedBefore: p.feed.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
444 }
445 if p.badges != nil {
446 return ProposalView{Badges: true, BadgesAction: p.badges.action, BadgesBefore: p.badges.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
447 }
448 if p.escrow != nil {
449 return ProposalView{Escrow: true, EscrowAction: p.escrow.action, EscrowBefore: p.escrow.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
450 }
451 if p.appstore != nil {
452 return ProposalView{Appstore: true, AppstoreAction: p.appstore.action, AppstoreBefore: p.appstore.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
453 }
454 if p.arcade != nil {
455 return ProposalView{Arcade: true, ArcadeAction: p.arcade.action, ArcadeBefore: p.arcade.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
456 }
457 if p.quest != nil {
458 return ProposalView{Quest: true, QuestAction: p.quest.action, QuestBefore: p.quest.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
459 }
460 if p.reviews != nil {
461 return ProposalView{Reviews: true, ReviewsAction: p.reviews.action, ReviewsBefore: p.reviews.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
462 }
463 if p.market != nil {
464 return ProposalView{Market: true, MarketAction: p.market.action, MarketBefore: p.market.before, ID: id, Proposer: p.proposer, State: p.ballot.State()}
465 }
466 return ProposalView{Recovery: p.recovery, PersonID: p.personID, Replacement: p.replacement, ID: id, Proposer: p.proposer, Target: p.target, Role: p.role, Grant: p.grant, State: p.ballot.State()}
467}
468
469func (h *Host) Members() []Seat {
470 out := []Seat{}
471 for _, seat := range h.seats {
472 out = append(out, seat)
473 }
474 return out
475}
476
477func (h *Host) ProposalCount() uint64 { return uint64(len(h.proposals)) }