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