governor.gno
6.07 Kb · 208 lines
1// Package governor is a simplified on-chain governance realm inspired by
2// OpenZeppelin's Governor. Anyone may open a proposal; every address gets a
3// single equally-weighted vote (1 address = 1 vote); a proposal succeeds when
4// its voting deadline has passed and the "for" tally strictly beats "against".
5package governor
6
7import (
8 "chain/markdown"
9 "strconv"
10 "strings"
11
12 "chain"
13 "chain/runtime"
14 "chain/runtime/unsafe"
15
16 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/store/v0"
17 "gno.land/p/nt/avl/v0"
18)
19
20// votingPeriod is the number of blocks a proposal stays open for voting.
21const votingPeriod = int64(100)
22
23// Vote support values.
24const (
25 VoteAgainst = 0
26 VoteFor = 1
27 VoteAbstain = 2
28)
29
30// Proposal state values.
31const (
32 StateActive = "Active"
33 StateSucceeded = "Succeeded"
34 StateDefeated = "Defeated"
35 StateExecuted = "Executed"
36)
37
38// Proposal is a single governance proposal. It carries no ID field: the id
39// belongs to the store, which hands it back on lookup and iteration.
40type Proposal struct {
41 Proposer address
42 Description string
43 SnapshotHeight int64
44 Deadline int64
45 For int64
46 Against int64
47 Abstain int64
48 Executed bool
49 votes *avl.Tree // voter address (string) -> support (int)
50}
51
52// proposals assigns the proposal ids. v0 kept its own nextID plus an idKey()
53// that zero-padded to width 12, which stopped ordering Render past 10^12, and
54// its own totalCount, which the store's LastID already answers.
55var proposals = store.Named("governor: proposal")
56
57// Propose opens a new proposal and returns its id. The snapshot height and
58// deadline are recorded from the current chain height.
59func Propose(cur realm, description string) int64 {
60 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(description)) == "" {
61 panic("governor: empty description")
62 }
63 proposer := unsafe.PreviousRealm().Address()
64 h := runtime.ChainHeight()
65 p := &Proposal{
66 Proposer: proposer,
67 Description: description,
68 SnapshotHeight: h,
69 Deadline: h + votingPeriod,
70 votes: avl.NewTree(),
71 }
72 id := int64(proposals.Add(p))
73
74 chain.Emit(
75 "ProposalCreated",
76 "id", strconv.FormatInt(id, 10),
77 "proposer", proposer.String(),
78 "deadline", strconv.FormatInt(p.Deadline, 10),
79 )
80 return id
81}
82
83// CastVote records one vote for the caller on proposal id. support is
84// 0=against, 1=for, 2=abstain. One address may vote at most once per proposal,
85// and voting is only allowed while the proposal is Active.
86func CastVote(cur realm, id int64, support int) {
87 p := mustGet(id)
88 if computeState(p, runtime.ChainHeight()) != StateActive {
89 panic("governor: voting closed")
90 }
91 if support < VoteAgainst || support > VoteAbstain {
92 panic("governor: invalid support value")
93 }
94 voter := unsafe.PreviousRealm().Address()
95 if p.votes.Has(voter.String()) {
96 panic("governor: already voted")
97 }
98 p.votes.Set(voter.String(), support)
99
100 switch support {
101 case VoteFor:
102 p.For++
103 case VoteAgainst:
104 p.Against++
105 default:
106 p.Abstain++
107 }
108
109 chain.Emit(
110 "VoteCast",
111 "id", strconv.FormatInt(id, 10),
112 "voter", voter.String(),
113 "support", strconv.Itoa(support),
114 )
115}
116
117// Execute marks a Succeeded proposal as executed. It panics unless the
118// proposal has reached the Succeeded state.
119func Execute(cur realm, id int64) {
120 p := mustGet(id)
121 if computeState(p, runtime.ChainHeight()) != StateSucceeded {
122 panic("governor: proposal not in Succeeded state")
123 }
124 p.Executed = true
125 chain.Emit("ProposalExecuted", "id", strconv.FormatInt(id, 10))
126}
127
128// State returns the current lifecycle state of proposal id.
129func State(id int64) string {
130 return computeState(mustGet(id), runtime.ChainHeight())
131}
132
133// computeState is the pure state machine: it decides the state of p at chain
134// height h. Kept free of globals so it is unit-testable.
135func computeState(p *Proposal, h int64) string {
136 if p.Executed {
137 return StateExecuted
138 }
139 if h < p.Deadline {
140 return StateActive
141 }
142 if p.For > p.Against {
143 return StateSucceeded
144 }
145 return StateDefeated
146}
147
148func mustGet(id int64) *Proposal {
149 return proposals.MustGet(store.ID(id)).(*Proposal)
150}
151
152// bar renders a proportional ▓░ progress bar of fixed width.
153func bar(value, total int64, width int) string {
154 if width <= 0 {
155 return ""
156 }
157 filled := 0
158 if total > 0 {
159 filled = int((value*int64(width) + total/2) / total)
160 if filled > width {
161 filled = width
162 }
163 }
164 return strings.Repeat("▓", filled) + strings.Repeat("░", width-filled)
165}
166
167// Render lists all proposals with their tallies and states as Markdown.
168func Render(path string) string {
169 var b strings.Builder
170 b.WriteString("# 🏛️ Governor\n\n")
171 b.WriteString("Simplified on-chain governance — 1 address = 1 vote, ")
172 b.WriteString("voting period of " + strconv.FormatInt(votingPeriod, 10) + " blocks.\n\n")
173
174 h := runtime.ChainHeight()
175 b.WriteString("Current chain height: **" + strconv.FormatInt(h, 10) + "**\n\n")
176
177 if proposals.Len() == 0 {
178 b.WriteString("_No proposals yet. Call `Propose` to create one._\n")
179 return b.String()
180 }
181
182 b.WriteString("Total proposals: **" + proposals.LastID().String() + "**\n\n")
183
184 proposals.Each(func(id store.ID, v any) {
185 p := v.(*Proposal)
186 state := computeState(p, h)
187 total := p.For + p.Against + p.Abstain
188
189 b.WriteString("---\n\n")
190 b.WriteString("## #" + id.String() + " · " + state + "\n\n")
191 b.WriteString("> " + p.Description + "\n\n")
192 b.WriteString("- Proposer: `" + p.Proposer.String() + "`\n")
193 b.WriteString("- Snapshot height: " + strconv.FormatInt(p.SnapshotHeight, 10) + "\n")
194 b.WriteString("- Deadline height: " + strconv.FormatInt(p.Deadline, 10))
195 if state == StateActive {
196 b.WriteString(" (" + strconv.FormatInt(p.Deadline-h, 10) + " blocks left)")
197 }
198 b.WriteString("\n\n")
199
200 b.WriteString("| Choice | Votes | Tally |\n")
201 b.WriteString("|---|---|---|\n")
202 b.WriteString("| For | " + strconv.FormatInt(p.For, 10) + " | `" + bar(p.For, total, 20) + "` |\n")
203 b.WriteString("| Against | " + strconv.FormatInt(p.Against, 10) + " | `" + bar(p.Against, total, 20) + "` |\n")
204 b.WriteString("| Abstain | " + strconv.FormatInt(p.Abstain, 10) + " | `" + bar(p.Abstain, total, 20) + "` |\n\n")
205 })
206
207 return b.String()
208}