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.

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}