vote.gno
7.84 Kb · 256 lines
1package nsvote
2
3import (
4 "chain/runtime/unsafe"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/nt/ufmt/v0"
10 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
11)
12
13// A name is the ballot. Not a token balance and not a GNOT balance:
14// holding a name is the one thing that cannot be borrowed for an
15// afternoon, and it is the population this actually concerns.
16func assertHolder(caller address) {
17 if nsdata.BalanceOf(caller) <= 0 {
18 panic("nsvote: only a name holder may take part")
19 }
20}
21
22/*
23assertNoSend refuses coins, on every entry point.
24
25types.gno states "It holds no funds, at any point, for anybody" — and
26until now nothing enforced it. This package contained no banker, no
27OriginSend and no withdrawal path of any kind, so ugnot attached to a
28Propose or a Vote landed at the realm's address and stayed there
29forever, unreachable even by a successor realm. Nobody would do it on
30purpose; a fat-fingered wallet or a script reusing a payment helper
31would.
32
33The early return matters: a call arriving from another realm carries no
34OriginSend of its own to inspect, and treating that as an error would
35break composition for a check that has nothing to look at.
36*/
37func assertNoSend(cur realm) {
38 if !cur.Previous().IsUserCall() {
39 return
40 }
41 if len(unsafe.OriginSend()) > 0 {
42 panic("nsvote: this call takes no payment")
43 }
44}
45
46// The admin is READ FROM THE VAULT rather than stored here. Two copies
47// of "who is in charge" is how a revoked admin keeps power somewhere
48// nobody remembered to look.
49func assertIsAdmin(caller address) {
50 if caller != nsdata.GetAdmin() {
51 panic("nsvote: admin only")
52 }
53}
54
55// Sortable keys, so paging is in creation order rather than the order
56// the strings happen to compare in. 000000001 sorts before 000000010.
57func key(id int64) string {
58 s := strconv.FormatInt(id, 10)
59 for len(s) < 9 {
60 s = "0" + s
61 }
62 return s
63}
64
65func voteKey(id int64, voter address) string { return key(id) + "/" + voter.String() }
66
67// The same shape a domain must have. A chain is asked for by its ticker,
68// which fits the same rule, so one check covers both — and it keeps a
69// proposal from asking for something that could never be created.
70func assertLabel(label string) {
71 if len(label) < 1 || len(label) > 63 {
72 panic("nsvote: a domain is 1 to 63 characters")
73 }
74 for i := 0; i < len(label); i++ {
75 c := label[i]
76 ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
77 if !ok {
78 panic("nsvote: lowercase letters, digits and hyphens only")
79 }
80 }
81 if label[0] == '-' || label[len(label)-1] == '-' {
82 panic("nsvote: a domain cannot start or end with a hyphen")
83 }
84}
85
86// Propose asks for a domain or for a chain. Anyone holding a name may ask.
87//
88// The proposer pays the storage for their own proposal, and nothing
89// else. That cost is the whole of the spam control: it is small enough
90// that a real request is nothing to think about and large enough that
91// filling the tree with rubbish is not free.
92func Propose(cur realm, kind, label, reason string) int64 {
93 assertNoSend(cur)
94 caller := cur.Previous().Address()
95 assertHolder(caller)
96 if kind != "domain" && kind != "chain" {
97 panic("nsvote: kind is domain or chain")
98 }
99 assertLabel(label)
100
101 if len(reason) > maxReason {
102 panic(ufmt.Sprintf("nsvote: a reason is at most %d characters", maxReason))
103 }
104 /* NEITHER A NEWLINE NOR A PIPE. read.gno joins a proposal's fields
105 with "|" and its comment claimed Propose enforced this; it did
106 not. A reason carrying pipes arrived at the site as extra fields,
107 and the site's parser accepted any row with AT LEAST ten — so the
108 reason's own pipes became the created/yes/no/status/decided
109 columns. One cheap name bought a permanent card reading
110 "9412 for / 3 against · accepted" on a proposal with no votes.
111 The chain state was always correct; the forgery lived entirely in
112 the rendering, which is what makes an unchecked separator in a
113 packed row worth a panic rather than an escape. */
114 if strings.ContainsAny(reason, "|\n\r") {
115 panic("nsvote: a reason may not contain '|' or a newline")
116 }
117 // Only a DOMAIN request can be checked against the registry. Whether
118 // a chain is already supported on profiles is a fact about the
119 // website, which this realm has no way to know and should not
120 // pretend to.
121 if kind == "domain" && nsdata.DomainExists(label) {
122 panic("nsvote: that domain already exists")
123 }
124
125 // One open request per label PER KIND. Two identical proposals split
126 // the vote and neither ever looks convincing — but a domain called
127 // solana and a chain called solana are different questions and must
128 // not block each other.
129 dup := false
130 proposals.Iterate("", "", func(k string, v any) bool {
131 p := v.(*Proposal)
132 if p.status == "open" && p.kind == kind && p.label == label {
133 dup = true
134 return true
135 }
136 return false
137 })
138 if dup {
139 panic("nsvote: there is already an open request for that")
140 }
141
142 id := nextID
143 nextID++
144 proposals.Set(key(id), &Proposal{
145 kind: kind, label: label, reason: reason, proposer: caller,
146 created: time.Now().Unix(), status: "open",
147 })
148 return id
149}
150
151// Vote records a yes or no. One per address per proposal, and it cannot
152// be changed: a vote that can be switched turns the count into a race
153// against whoever is watching, and there is no deadline here to make
154// that fair.
155func Vote(cur realm, id int64, yes bool) {
156 assertNoSend(cur)
157 caller := cur.Previous().Address()
158 assertHolder(caller)
159
160 v := proposals.Get(key(id))
161 if v == nil {
162 panic("nsvote: no such request")
163 }
164 p := v.(*Proposal)
165 if p.status != "open" {
166 panic("nsvote: that request is closed")
167 }
168 if votes.Get(voteKey(id, caller)) != nil {
169 panic("nsvote: you have already voted on this one")
170 }
171
172 mark := "n"
173 if yes {
174 mark = "y"
175 p.yes++
176 } else {
177 p.no++
178 }
179 votes.Set(voteKey(id, caller), mark)
180}
181
182// Withdraw closes a request the proposer no longer wants. The votes
183// already cast stay: deleting them would refund their storage to
184// whoever called this, which is not the person who paid it.
185func Withdraw(cur realm, id int64) {
186 assertNoSend(cur)
187 caller := cur.Previous().Address()
188 v := proposals.Get(key(id))
189 if v == nil {
190 panic("nsvote: no such request")
191 }
192 p := v.(*Proposal)
193 if p.proposer != caller {
194 panic("nsvote: only the proposer may withdraw it")
195 }
196 if p.status != "open" {
197 panic("nsvote: that request is already closed")
198 }
199 p.status = "withdrawn"
200 p.decided = time.Now().Unix()
201}
202
203// Resolve records what was decided. It does NOT create the domain —
204// that stays a separate, deliberate call in nslogic. A vote is advice,
205// and the vault has no undo.
206func Resolve(cur realm, id int64, accepted bool) {
207 assertNoSend(cur)
208 assertIsAdmin(cur.Previous().Address())
209 v := proposals.Get(key(id))
210 if v == nil {
211 panic("nsvote: no such request")
212 }
213 p := v.(*Proposal)
214 if p.status != "open" {
215 panic("nsvote: that request is already closed")
216 }
217 if accepted {
218 p.status = "accepted"
219 } else {
220 p.status = "rejected"
221 }
222 p.decided = time.Now().Unix()
223}
224
225// Purge deletes a closed request and its votes, releasing the storage.
226//
227// The refund goes to WHOEVER SIGNS THIS, which is not the people who
228// paid — so it is admin-only and it is not a tidying operation to run
229// casually. It exists because a registry that can only ever grow is
230// worse, not because reclaiming somebody else's deposit is free money.
231func Purge(cur realm, id int64) int64 {
232 assertNoSend(cur)
233 assertIsAdmin(cur.Previous().Address())
234 v := proposals.Get(key(id))
235 if v == nil {
236 panic("nsvote: no such request")
237 }
238 p := v.(*Proposal)
239 if p.status == "open" {
240 panic("nsvote: an open request cannot be purged")
241 }
242
243 // Collect first, mutate after: removing during an iteration walks a
244 // tree that is changing underneath the walk.
245 prefix := key(id) + "/"
246 var doomed []string
247 votes.Iterate(prefix, prefix+"\xff", func(k string, _ any) bool {
248 doomed = append(doomed, k)
249 return false
250 })
251 for _, k := range doomed {
252 votes.Remove(k)
253 }
254 proposals.Remove(key(id))
255 return int64(len(doomed))
256}