proposals.gno
5.26 Kb · 177 lines
1package daokit
2
3// Handles DAO governance through proposal voting and execution.
4// They contain actions that run when voting requirements are satisfied.
5//
6// Common use cases: member management, treasury operations, rule updates.
7
8import (
9 "chain/runtime"
10 "errors"
11 "time"
12
13 "gno.land/p/nt/seqid/v0"
14 "gno.land/p/onbloc/json"
15 "gno.land/p/samcrew/avl"
16 "gno.land/p/samcrew/daocond"
17)
18
19type ProposalStatus int
20
21const (
22 ProposalStatusOpen ProposalStatus = iota // Currently accepting votes
23 ProposalStatusPassed // Met conditions, ready for execution
24 ProposalStatusExecuted // Successfully executed
25)
26
27func (s ProposalStatus) String() string {
28 switch s {
29 case ProposalStatusOpen:
30 return "Open"
31 case ProposalStatusPassed:
32 return "Passed"
33 case ProposalStatusExecuted:
34 return "Executed"
35 default:
36 return "Unknown"
37 }
38}
39
40type Proposal struct {
41 ID seqid.ID
42 Title string
43 Description string
44 CreatedAt time.Time
45 CreatedHeight int64
46 ProposerID string
47
48 Action Action // What to execute if passed
49 Condition daocond.Condition // Voting conditions for this proposal
50 Status ProposalStatus
51 ExecutedAt time.Time
52
53 Ballot daocond.Ballot // All votes cast on this proposal
54}
55
56type ProposalsStore struct {
57 Tree *avl.Tree // int -> Proposal
58 genID seqid.ID
59}
60
61// Data needed to create a new proposal.
62type ProposalRequest struct {
63 Title string
64 Description string
65 Action Action
66}
67
68func NewProposalsStore() *ProposalsStore {
69 return &ProposalsStore{
70 Tree: avl.NewTree(),
71 }
72}
73
74func (p *ProposalsStore) GetProposal(id uint64) *Proposal {
75 value, ok := p.Tree.Get(seqid.ID(id).String())
76 if !ok {
77 return nil
78 }
79 proposal := value.(*Proposal)
80 return proposal
81}
82
83// Returns all proposals that match the given filter function.
84func (p *ProposalsStore) GetProposals(filter func(Proposal) bool) *ProposalsStore {
85 proposals := NewProposalsStore()
86 p.Tree.Iterate("", "", func(key string, value interface{}) bool {
87 prop, ok := value.(*Proposal)
88 if !ok {
89 panic(errors.New("unexpected invalid proposal type"))
90 }
91 if filter(*prop) {
92 proposals.Tree.Set(key, prop)
93 }
94 return false
95 })
96 return proposals
97}
98
99// Reports the status a proposal should be displayed as, without writing it.
100//
101// Reading must never move a proposal. Render is on the cross-realm DAO
102// interface, so anything it touches is reachable by any realm holding the
103// handle: every read path used to call UpdateStatus on each proposal it walked,
104// and a single Render flipped every condition-met proposal from Open to Passed —
105// which, back when Execute demanded Open, bricked it permanently.
106func (p *Proposal) DisplayStatus() ProposalStatus {
107 if p.Status == ProposalStatusOpen && p.Condition.Eval(p.Ballot) {
108 return ProposalStatusPassed
109 }
110 return p.Status
111}
112
113// Persists the status implied by the current votes: Open becomes Passed once the
114// condition is met.
115//
116// Nothing in this package calls it. Execute evaluates the condition itself and
117// the read paths use DisplayStatus, so a proposal that is never passed through
118// here stays Open until it is Executed. It exists for a realm that wants the
119// passed state on-chain rather than computed.
120//
121// SAFE ONLY BECAUSE Vote and Execute both key on Executed alone. When they
122// demanded Open, calling this was destructive twice over: it froze the ballot,
123// so support could not be withdrawn, and it made the proposal permanently
124// unexecutable. Do not narrow either of those checks without removing this.
125//
126// It is also a READ path hazard — do not call it while rendering. And once
127// persisted the status no longer tracks the votes: withdraw support afterwards
128// and this still reads Passed, though Execute re-evaluates the condition and
129// will refuse.
130func (p *Proposal) UpdateStatus() {
131 p.Status = p.DisplayStatus()
132}
133
134// Returns all proposals as a JSON string.
135func (p *ProposalsStore) GetProposalsJSON() string {
136 props := make([]*json.Node, 0, p.Tree.Size())
137 // XXX: pagination
138 p.Tree.Iterate("", "", func(key string, value interface{}) bool {
139 prop, ok := value.(*Proposal)
140 if !ok {
141 panic(errors.New("unexpected invalid proposal type"))
142 }
143 props = append(props, json.ObjectNode("", map[string]*json.Node{
144 "id": json.NumberNode("", float64(prop.ID)),
145 "title": json.StringNode("", prop.Title),
146 "description": json.StringNode("", prop.Description),
147 "proposer": json.StringNode("", prop.ProposerID),
148 "status": json.StringNode("", prop.DisplayStatus().String()),
149 "startHeight": json.NumberNode("", float64(prop.CreatedHeight)),
150 "signal": json.NumberNode("", prop.Condition.Signal(prop.Ballot)),
151 }))
152 return false
153 })
154 bz, err := json.Marshal(json.ArrayNode("", props))
155 if err != nil {
156 panic(err)
157 }
158 return string(bz)
159}
160
161func (p *ProposalsStore) newProposal(proposer string, req ProposalRequest, condition daocond.Condition) *Proposal {
162 id := p.genID.Next()
163 proposal := &Proposal{
164 ID: id,
165 Title: req.Title,
166 Description: req.Description,
167 ProposerID: proposer,
168 Status: ProposalStatusOpen,
169 Action: req.Action,
170 Condition: condition,
171 Ballot: daocond.NewBallot(),
172 CreatedAt: time.Now(),
173 CreatedHeight: runtime.ChainHeight(),
174 }
175 p.Tree.Set(id.String(), proposal)
176 return proposal
177}