gov.gno
10.83 Kb · 432 lines
1// Package gov is the WIZ GovDAO treasury, token version 4.
2//
3// Seal on gno.land/r/nym-wizards001/drop sends two sleeves here:
4// the incentive sleeve (7% of supply) and community development
5// (3% plus the pro-rata remainder). A vote spends one sleeve.
6// It cannot spend the other. Liquidity was sent to the address named
7// in Seal, and this realm cannot move it.
8//
9// Voting weight is the signer's WIZ balance. One vote per address.
10// The window is 7 days, the same duration as drop.VoteWindow.
11// A spend passes when yes beats no and yes reaches drop.Quorum,
12// 10% of the sealed airdrop. There is no withdraw and no listing vote.
13package gov
14
15import (
16 "chain"
17 "chain/runtime/unsafe"
18 "strconv"
19 "strings"
20 "time"
21
22 "gno.land/p/nt/avl/v0"
23 "gno.land/p/nt/ufmt/v0"
24
25 "gno.land/r/nym-wizards001/drop"
26)
27
28const (
29 // KindSpend pays community WIZ to the named address.
30 // The memo is optional and is not spent.
31 KindSpend = 1
32 // KindSignal stores a memo and moves nothing. Kind 2 is unused.
33 KindSignal = 3
34 // KindIncentive pays incentive WIZ to the named address.
35 // The memo is optional and is not spent.
36 KindIncentive = 4
37
38 // VoteWindow matches drop.VoteWindow. A ballot locks the voter's balance for this long.
39 VoteWindow int64 = 7 * 24 * 60 * 60
40 // maxMemo is the packed note: a title, a summary, and a change.
41 maxMemo = 1200
42
43 notePrefix = "wizards-note/1\n"
44 noteSplit = "\n---\n"
45)
46
47type proposal struct {
48 kind int
49 proposer address
50 to address
51 tokenAmt int64
52 memo string
53 start int64
54 end int64
55 yes int64
56 no int64
57 executed bool
58 passed bool
59}
60
61type ballot struct {
62 yes bool
63 weight int64
64}
65
66var (
67 treasury address
68 spentCommunity int64
69 spentIncentive int64
70 proposals avl.Tree
71 votes avl.Tree
72 nextProposal int
73)
74
75// Bind records this realm as the drop token's GovDAO.
76// The transaction signer must be the token admin. One call, before Seal.
77func Bind(cur realm) {
78 mustUser(cur)
79 mustNoPayment()
80 if treasury.IsValid() {
81 panic("bound")
82 }
83 // SetGov checks the signer before this realm records itself.
84 // A rejected call must not leave treasury set.
85 drop.SetGov(cross(cur))
86 treasury = cur.Address()
87 chain.Emit("Bind", "treasury", treasury.String())
88}
89
90// Propose opens a vote. Claims on the token must already have started.
91// The caller must hold WIZ. kind is KindSpend, KindSignal, or KindIncentive.
92func Propose(cur realm, kind int, to address, tokenAmt int64, memo string) int {
93 mustUser(cur)
94 mustNoPayment()
95 if !treasury.IsValid() {
96 panic("gov")
97 }
98 if !drop.ClaimsStarted() {
99 panic("claim not started")
100 }
101 caller := cur.Previous().Address()
102 if drop.BalanceOf(caller) <= 0 {
103 panic("proposer")
104 }
105 switch kind {
106 case KindSpend:
107 if len(memo) > maxMemo {
108 panic("proposal")
109 }
110 mustPayProposal(to, tokenAmt, CommunityLeft())
111 case KindIncentive:
112 if len(memo) > maxMemo {
113 panic("proposal")
114 }
115 mustPayProposal(to, tokenAmt, IncentiveLeft())
116 case KindSignal:
117 if to.IsValid() || tokenAmt != 0 {
118 panic("proposal")
119 }
120 if len(memo) == 0 || len(memo) > maxMemo {
121 panic("proposal")
122 }
123 default:
124 panic("proposal")
125 }
126 now := time.Now().Unix()
127 id := nextProposal
128 nextProposal++
129 proposals.Set(strconv.Itoa(id), &proposal{
130 kind: kind,
131 proposer: caller,
132 to: to,
133 tokenAmt: tokenAmt,
134 memo: memo,
135 start: now,
136 end: now + VoteWindow,
137 })
138 toText := ""
139 if to.IsValid() {
140 toText = to.String()
141 }
142 chain.Emit(
143 "Propose",
144 "id", strconv.Itoa(id),
145 "kind", strconv.Itoa(kind),
146 "proposer", caller.String(),
147 "to", toText,
148 "tokenAmt", strconv.FormatInt(tokenAmt, 10),
149 "memo", memo,
150 )
151 return id
152}
153
154// Vote records the caller's current WIZ balance as yes or no.
155// One vote per address. That balance stays locked until this proposal ends.
156func Vote(cur realm, id int, yes bool) {
157 mustUser(cur)
158 mustNoPayment()
159 p := proposalOf(id)
160 now := time.Now().Unix()
161 if now < p.start || now >= p.end {
162 panic("voting")
163 }
164 caller := cur.Previous().Address()
165 key := voteKey(id, caller)
166 if votes.Has(key) {
167 panic("voted")
168 }
169 weight := drop.BalanceOf(caller)
170 if weight <= 0 {
171 panic("vote weight")
172 }
173 drop.NoteVote(cross(cur), p.end)
174 votes.Set(key, &ballot{yes: yes, weight: weight})
175 if yes {
176 p.yes += weight
177 } else {
178 p.no += weight
179 }
180 proposals.Set(strconv.Itoa(id), p)
181 yesText := "0"
182 if yes {
183 yesText = "1"
184 }
185 chain.Emit(
186 "Vote",
187 "id", strconv.Itoa(id),
188 "yes", yesText,
189 "voter", caller.String(),
190 "weight", strconv.FormatInt(weight, 10),
191 )
192}
193
194// Execute closes a finished proposal. A passed spend pays one sleeve.
195// A failed vote moves nothing. Anyone may call it.
196func Execute(cur realm, id int) {
197 mustCurrent(cur)
198 mustNoPayment()
199 p := proposalOf(id)
200 if p.executed {
201 panic("executed")
202 }
203 if time.Now().Unix() < p.end {
204 panic("voting")
205 }
206 passed := p.yes > p.no && p.yes >= drop.Quorum()
207 if passed && !executable(p) {
208 passed = false
209 }
210 p.executed = true
211 p.passed = passed
212 proposals.Set(strconv.Itoa(id), p)
213 if !passed {
214 chain.Emit("Reject", "id", strconv.Itoa(id))
215 return
216 }
217 switch p.kind {
218 case KindSpend:
219 drop.Transfer(cross(cur), p.to, p.tokenAmt)
220 spentCommunity += p.tokenAmt
221 case KindIncentive:
222 drop.Transfer(cross(cur), p.to, p.tokenAmt)
223 spentIncentive += p.tokenAmt
224 case KindSignal:
225 default:
226 panic("proposal")
227 }
228 chain.Emit("Execute", "id", strconv.Itoa(id), "kind", strconv.Itoa(p.kind))
229}
230
231func Treasury() int64 {
232 if !treasury.IsValid() {
233 return 0
234 }
235 return drop.BalanceOf(treasury)
236}
237
238// CommunityLeft is the community sleeve still available to a vote.
239func CommunityLeft() int64 {
240 return sleeveLeft(drop.CommunitySent(), spentCommunity)
241}
242
243// IncentiveLeft is the incentive sleeve still available to a vote.
244func IncentiveLeft() int64 {
245 return sleeveLeft(drop.IncentiveSent(), spentIncentive)
246}
247
248func TreasuryAddress() address { return treasury }
249
250func ProposalCount() int { return nextProposal }
251
252func ProposalKind(id int) int { return proposalOf(id).kind }
253func ProposalYes(id int) int64 { return proposalOf(id).yes }
254func ProposalNo(id int) int64 { return proposalOf(id).no }
255func ProposalExecuted(id int) bool { return proposalOf(id).executed }
256func ProposalPassed(id int) bool { return proposalOf(id).passed }
257func ProposalEnd(id int) int64 { return proposalOf(id).end }
258func ProposalTokenAmt(id int) int64 {
259 return proposalOf(id).tokenAmt
260}
261func ProposalTo(id int) address { return proposalOf(id).to }
262func ProposalMemo(id int) string {
263 return proposalOf(id).memo
264}
265func ProposalProposer(id int) address {
266 return proposalOf(id).proposer
267}
268
269// Voted reports whether voter has a ballot on id. A missing ballot is (false, false).
270func Voted(id int, voter address) (cast bool, yes bool) {
271 if id < 0 || id >= nextProposal || !voter.IsValid() {
272 return false, false
273 }
274 v := votes.Get(voteKey(id, voter))
275 if v == nil {
276 return false, false
277 }
278 b, ok := v.(*ballot)
279 if !ok || b == nil {
280 panic("vote")
281 }
282 return true, b.yes
283}
284
285// Render prints one proposal at "proposal/<id>". Other paths print the treasury.
286func Render(path string) string {
287 const proposalPrefix = "proposal/"
288 if len(path) >= len(proposalPrefix) && path[:len(proposalPrefix)] == proposalPrefix {
289 return renderProposal(path[len(proposalPrefix):])
290 }
291 out := "# WIZ GovDAO\n\n"
292 out += "* **Community development**: one sleeve, spent by a passed vote\n"
293 out += "* **Incentive**: a separate sleeve, spent by a passed vote\n"
294 out += "* **Liquidity**: not held here\n"
295 out += "* **Vote**: WIZ balance, 7 days, quorum 10% of the sealed airdrop\n"
296 out += "* **No withdraw**\n"
297 if treasury.IsValid() {
298 out += ufmt.Sprintf("\n* **Balance**: %d\n", Treasury())
299 out += ufmt.Sprintf("* **Community left**: %d\n", CommunityLeft())
300 out += ufmt.Sprintf("* **Incentive left**: %d\n", IncentiveLeft())
301 }
302 out += ufmt.Sprintf("* **Proposals**: %d\n", nextProposal)
303 return out
304}
305
306func renderProposal(idText string) string {
307 id, err := strconv.Atoi(idText)
308 if err != nil || id < 0 || id >= nextProposal {
309 return "# WIZ GovDAO\n\nProposal not found.\n"
310 }
311 p := proposalOf(id)
312 toText := ""
313 if p.to.IsValid() {
314 toText = p.to.String()
315 }
316 out := ufmt.Sprintf("# WIZ proposal %d\n\n", id)
317 out += ufmt.Sprintf("* **Kind**: %d\n", p.kind)
318 out += ufmt.Sprintf("* **Proposer**: %s\n", p.proposer.String())
319 out += ufmt.Sprintf("* **To**: %s\n", toText)
320 out += ufmt.Sprintf("* **Token**: %d\n", p.tokenAmt)
321 out += renderMemo(p.memo)
322 out += ufmt.Sprintf("* **Yes**: %d\n", p.yes)
323 out += ufmt.Sprintf("* **No**: %d\n", p.no)
324 out += ufmt.Sprintf("* **Quorum**: %d\n", drop.Quorum())
325 out += ufmt.Sprintf("* **End**: %d\n", p.end)
326 out += ufmt.Sprintf("* **Executed**: %t\n", p.executed)
327 out += ufmt.Sprintf("* **Passed**: %t\n", p.passed)
328 return out
329}
330
331func renderMemo(memo string) string {
332 title, summary, change, ok := splitNote(memo)
333 if !ok {
334 return ufmt.Sprintf("* **Memo**: %s\n", oneLine(memo))
335 }
336 out := ufmt.Sprintf("* **Title**: %s\n", oneLine(title))
337 out += ufmt.Sprintf("* **Summary**: %s\n", oneLine(summary))
338 out += ufmt.Sprintf("* **Change**: %s\n", oneLine(change))
339 return out
340}
341
342func splitNote(memo string) (title, summary, change string, ok bool) {
343 if len(memo) < len(notePrefix) || memo[:len(notePrefix)] != notePrefix {
344 return "", "", "", false
345 }
346 parts := strings.SplitN(memo[len(notePrefix):], noteSplit, 3)
347 if len(parts) != 3 {
348 return "", "", "", false
349 }
350 return parts[0], parts[1], parts[2], true
351}
352
353func oneLine(s string) string {
354 b := []byte(s)
355 for i := 0; i < len(b); i++ {
356 if b[i] < 0x20 || b[i] == 0x7f {
357 b[i] = ' '
358 }
359 }
360 return string(b)
361}
362
363func executable(p *proposal) bool {
364 switch p.kind {
365 case KindSpend:
366 return payable(p, CommunityLeft())
367 case KindIncentive:
368 return payable(p, IncentiveLeft())
369 case KindSignal:
370 return true
371 default:
372 return false
373 }
374}
375
376func payable(p *proposal, left int64) bool {
377 if p.tokenAmt <= 0 || p.tokenAmt > left || !p.to.IsValid() || p.to == treasury || p.to == drop.Escrow() {
378 return false
379 }
380 return p.tokenAmt <= drop.BalanceOf(treasury)
381}
382
383func mustPayProposal(to address, tokenAmt int64, left int64) {
384 if !to.IsValid() || to == treasury || to == drop.Escrow() || tokenAmt <= 0 || tokenAmt > left || tokenAmt > drop.BalanceOf(treasury) {
385 panic("proposal")
386 }
387}
388
389func sleeveLeft(sent, spent int64) int64 {
390 if spent < 0 || sent < spent {
391 panic("sleeve")
392 }
393 return sent - spent
394}
395
396func proposalOf(id int) *proposal {
397 if id < 0 || id >= nextProposal {
398 panic("proposal")
399 }
400 v := proposals.Get(strconv.Itoa(id))
401 if v == nil {
402 panic("proposal")
403 }
404 p, ok := v.(*proposal)
405 if !ok || p == nil {
406 panic("proposal")
407 }
408 return p
409}
410
411func voteKey(id int, voter address) string {
412 return strconv.Itoa(id) + ":" + voter.String()
413}
414
415func mustCurrent(cur realm) {
416 if !cur.IsCurrent() {
417 panic("realm")
418 }
419}
420
421func mustUser(cur realm) {
422 mustCurrent(cur)
423 if !cur.Previous().IsUserCall() {
424 panic("user call only")
425 }
426}
427
428func mustNoPayment() {
429 if len(unsafe.OriginSend()) != 0 {
430 panic("unexpected payment")
431 }
432}