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

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)) }