app.gno
10.86 Kb · 381 lines
1// Package contest is a Remix Lab "demo-contest" app generated
2// from a project configuration. Entrants submit through a fixed form,
3// reviewers admit up to 10 entries, and members vote for a winner.
4//
5// The contest runs once: opening the vote closes submissions and review,
6// freezes the admitted entries as the ballot's options and captures the
7// current members as the electorate. Every submission is public chain
8// data, including entries that are later rejected.
9//
10// Authority model: every write derives the actor from cur.Previous(), the
11// immediate caller. An intermediary realm calling on a user's behalf acts as
12// itself and gains none of that user's roles. Block state lives in
13// unexported variables and only inert views leave the realm.
14package contest
15
16import (
17 "strconv"
18 "time"
19
20 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/ballots/v1"
21 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/core/v1"
22 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/forms/v1"
23 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/membership/v1"
24 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/policies/v1"
25)
26
27const (
28 pkgPath = "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/contest"
29 appTitle = "Demo contest"
30 recipeID = "demo-contest"
31 recipeVersion = "1.0.0"
32 pageSize = 25
33 maxEntries = 10
34 maxElectorate = 100
35)
36
37// titleIndex is the form field shown as each entry's title.
38const titleIndex = 0
39
40// Operation names checked against the policy.
41const (
42 opInvite = "members.invite"
43 opRemove = "members.remove"
44 opSetRole = "members.set_role"
45 opSubmit = "entry.submit"
46 opReview = "entry.review"
47 opOpen = "contest.open"
48 opPolicy = "policy.set"
49)
50
51var (
52 members *membership.Set
53 policy *policies.Policy
54 entries *forms.Form
55 ballot *ballots.Box
56
57 // ballotID is 0 until voting opens; ballotEntries maps each ballot
58 // option index to its entry ID.
59 ballotID uint64
60 ballotEntries []uint64
61)
62
63// init captures the deployer as the first owner. A realm whose deployer is
64// not a user stays uninitialized and rejects every write; there is no
65// separate initialization call that someone else could front-run.
66func init(cur realm) {
67 deployer := cur.Previous()
68 if deployer.IsUser() && deployer.Address().IsValid() {
69 setup(deployer.Address())
70 }
71}
72
73func setup(owner address) {
74 members = membership.New(membership.Config{
75 JoinMode: membership.JoinInvitation,
76 MaxMembers: maxElectorate,
77 }, owner)
78
79 policy = policies.New(members)
80 policy.Define(opInvite, policies.RuleOwner, policies.RuleReviewer)
81 policy.Define(opRemove, policies.RuleOwner, policies.RuleOwner)
82 policy.Define(opSetRole, policies.RuleOwner, policies.RuleOwner)
83 policy.Define(opSubmit, policies.RulePublic, policies.RulePublic)
84 policy.Define(opReview, policies.RuleReviewer, policies.RuleReviewer)
85 policy.Define(opOpen, policies.RuleOwner, policies.RuleReviewer)
86 policy.Define(opPolicy, policies.RuleOwner, policies.RuleOwner)
87
88 entries = forms.New(forms.Config{
89 MaxSubmissions: 50,
90 MaxPerSubmitter: 1,
91 MaxPayloadBytes: 512,
92 }, []forms.Field{
93 {Name: "title", Label: "Demo title", Kind: forms.KindText, Required: true, MaxBytes: 80},
94 {Name: "link", Label: "Where to see it", Kind: forms.KindText, Required: true, MaxBytes: 200},
95 {Name: "team", Label: "Team", Kind: forms.KindText, Required: false, MaxBytes: 120},
96 })
97
98 ballot = ballots.NewBox(ballots.Config{
99 MaxBallots: 1,
100 MaxOptions: maxEntries,
101 MaxElectorate: maxElectorate,
102 QuorumPercent: 0,
103 AllowAbstain: false,
104 MinDuration: 60,
105 MaxDuration: 604800,
106 MaxTitleBytes: 256,
107 MaxOptionBytes: 102,
108 })
109 ballotID = 0
110 ballotEntries = nil
111}
112
113// --- Entries ---
114
115// Submit stores a contest entry for review and returns its ID. Every answer
116// is public. Leave optional fields empty.
117// - title: required text, at most 80 bytes
118// - link: required text, at most 200 bytes
119// - team: optional text, at most 120 bytes
120func Submit(cur realm, title string, link string, team string) uint64 {
121 actor := authorize(cur, opSubmit)
122 mustBeOpenForEntries()
123 id, err := entries.Submit(actor, []string{title, link, team})
124 must(err)
125 return id
126}
127
128// Accept admits a submitted entry to the ballot. Decisions are final.
129func Accept(cur realm, id uint64) {
130 authorize(cur, opReview)
131 mustBeOpenForEntries()
132 if entries.CountByStatus(core.SubmissionAccepted) >= maxEntries {
133 panic(core.Errorf(core.ErrLimitExceeded, "the contest already has "+strconv.Itoa(maxEntries)+" entries").Error())
134 }
135 must(entries.Review(id, core.SubmissionAccepted))
136}
137
138// Reject declines a submitted entry. Decisions are final.
139func Reject(cur realm, id uint64) {
140 authorize(cur, opReview)
141 mustBeOpenForEntries()
142 must(entries.Review(id, core.SubmissionRejected))
143}
144
145// --- Voting ---
146
147// OpenVoting closes submissions and review, turns the admitted entries into
148// the ballot's options, captures the current members as the electorate and
149// opens voting for durationSeconds.
150func OpenVoting(cur realm, durationSeconds int64) {
151 actor := authorize(cur, opOpen)
152 mustBeOpenForEntries()
153 admitted := entries.Submissions(int(core.SubmissionAccepted), 0, maxEntries)
154 if len(admitted) < 2 {
155 panic(core.Errorf(core.ErrInvalidState, "at least two admitted entries are required").Error())
156 }
157 options := make([]string, len(admitted))
158 ids := make([]uint64, len(admitted))
159 for i, e := range admitted {
160 ids[i] = e.ID
161 options[i] = "#" + strconv.FormatUint(e.ID, 10) + " " + e.Answers[titleIndex]
162 }
163 id, err := ballot.Create(actor, appTitle, options, durationSeconds)
164 must(err)
165 electorate, ok := members.Snapshot(core.RoleMember, maxElectorate)
166 if !ok {
167 panic(core.Errorf(core.ErrLimitExceeded, "electorate too large").Error())
168 }
169 must(ballot.OpenWithElectorate(id, time.Now().Unix(), electorate))
170 ballotID = id
171 ballotEntries = ids
172}
173
174// Vote casts the caller's vote for an admitted entry.
175func Vote(cur realm, entryID uint64) {
176 ready()
177 if ballotID == 0 {
178 panic(core.Errorf(core.ErrInvalidState, "voting has not opened").Error())
179 }
180 choice := -1
181 for i, id := range ballotEntries {
182 if id == entryID {
183 choice = i
184 }
185 }
186 if choice < 0 {
187 panic(core.Errorf(core.ErrNotFound, "entry is not on the ballot").Error())
188 }
189 must(ballot.Vote(ballotID, cur.Previous().Address(), choice, time.Now().Unix()))
190}
191
192// Finalize commits the result once the deadline has passed. Anyone may call
193// it; the result depends only on rules frozen when voting opened.
194func Finalize(cur realm) {
195 ready()
196 if ballotID == 0 {
197 panic(core.Errorf(core.ErrInvalidState, "voting has not opened").Error())
198 }
199 _, err := ballot.Finalize(ballotID, time.Now().Unix())
200 must(err)
201}
202
203// --- Membership ---
204
205// Invite lets addr join with role. Inviting a reviewer or owner also
206// requires the members.set_role permission.
207func Invite(cur realm, addr address, role string) {
208 actor := authorize(cur, opInvite)
209 r, err := core.ParseRole(role)
210 must(err)
211 if r > core.RoleMember {
212 must(policy.Authorize(opSetRole, actor))
213 }
214 must(members.Invite(addr, r))
215}
216
217// RevokeInvite cancels a pending invitation. Revoking an invitation for a
218// reviewer or owner also requires the members.set_role permission.
219func RevokeInvite(cur realm, addr address) {
220 actor := authorize(cur, opInvite)
221 if r, ok := members.InviteRole(addr); ok && r > core.RoleMember {
222 must(policy.Authorize(opSetRole, actor))
223 }
224 must(members.RevokeInvite(addr))
225}
226
227// Join accepts the caller's pending invitation.
228func Join(cur realm) {
229 ready()
230 must(members.Join(cur.Previous().Address()))
231}
232
233// Leave removes the caller from the membership. An open vote keeps its frozen
234// electorate.
235func Leave(cur realm) {
236 ready()
237 must(members.Leave(cur.Previous().Address()))
238}
239
240// RemoveMember removes addr.
241func RemoveMember(cur realm, addr address) {
242 authorize(cur, opRemove)
243 must(members.Remove(addr))
244}
245
246// SetRole changes addr's role.
247func SetRole(cur realm, addr address, role string) {
248 authorize(cur, opSetRole)
249 r, err := core.ParseRole(role)
250 must(err)
251 must(members.SetRole(addr, r))
252}
253
254// --- Policy ---
255
256// SetRule changes the rule for an operation, within that operation's floor.
257func SetRule(cur realm, op string, rule string) {
258 authorize(cur, opPolicy)
259 r, err := policies.ParseRule(rule)
260 must(err)
261 must(policy.Set(op, r))
262}
263
264// --- Reads ---
265
266// Entry returns an inert copy of entry id in any status.
267func Entry(id uint64) (core.SubmissionView, bool) {
268 ready()
269 return entries.Submission(id)
270}
271
272// Entries lists the admitted entries in submission order.
273func Entries() []core.SubmissionView {
274 ready()
275 return entries.Submissions(int(core.SubmissionAccepted), 0, maxEntries)
276}
277
278// Queue pages through entries awaiting review in submission order.
279func Queue(offset, limit int) []core.SubmissionView {
280 ready()
281 return entries.Submissions(int(core.SubmissionSubmitted), offset, clampLimit(limit))
282}
283
284// Fields returns a copy of the form schema.
285func Fields() []forms.Field {
286 ready()
287 return entries.Fields()
288}
289
290// Ballot returns the contest ballot once voting has opened.
291func Ballot() (core.BallotView, bool) {
292 ready()
293 if ballotID == 0 {
294 return core.BallotView{}, false
295 }
296 return ballot.Ballot(ballotID)
297}
298
299// BallotEntries returns the entry ID of each ballot option, in option order.
300func BallotEntries() []uint64 {
301 ready()
302 out := make([]uint64, len(ballotEntries))
303 copy(out, ballotEntries)
304 return out
305}
306
307// Winner returns the winning entry once the result is final and has a
308// single winner.
309func Winner() (uint64, bool) {
310 ready()
311 v, ok := Ballot()
312 if !ok || v.Status != core.BallotFinalized || v.Outcome != core.OutcomeWinner {
313 return 0, false
314 }
315 return ballotEntries[v.Winner], true
316}
317
318// HasVoted reports whether addr has voted.
319func HasVoted(addr address) bool {
320 ready()
321 return ballotID != 0 && ballot.HasVoted(ballotID, addr)
322}
323
324// Members pages through members ordered by address.
325func Members(offset, limit int) []core.MemberView {
326 ready()
327 return members.List(offset, clampLimit(limit))
328}
329
330// Invites returns up to limit pending invitations (at most 25) starting at
331// offset, with the role each one grants.
332func Invites(offset, limit int) []core.MemberView {
333 ready()
334 return members.Invites(offset, clampLimit(limit))
335}
336
337// RoleOf returns addr's role name.
338func RoleOf(addr address) string {
339 ready()
340 return members.RoleOf(addr).String()
341}
342
343// Rules lists every operation's current rule.
344func Rules() []policies.RuleView {
345 ready()
346 return policy.List()
347}
348
349// --- Internals ---
350
351func authorize(cur realm, op string) address {
352 ready()
353 actor := cur.Previous().Address()
354 must(policy.Authorize(op, actor))
355 return actor
356}
357
358func mustBeOpenForEntries() {
359 if ballotID != 0 {
360 panic(core.Errorf(core.ErrInvalidState, "submissions are closed").Error())
361 }
362}
363
364func ready() {
365 if members == nil {
366 panic(core.Errorf(core.ErrInvalidState, "app is not initialized").Error())
367 }
368}
369
370func clampLimit(limit int) int {
371 if limit < 1 || limit > pageSize {
372 return pageSize
373 }
374 return limit
375}
376
377func must(err error) {
378 if err != nil {
379 panic(err.Error())
380 }
381}