Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}