tickets.gno
18.44 Kb · 540 lines
1// Package tickets is GnoRadio's concert tickets rules, release 1. Concerts
2// belong to artist profiles of the catalog; a ticket is a GRC721 NFT of the
3// permanent realm gno.land/r/nym-alexiscolin000/gnoradio/tickets/nft, drawn on-chain. Ticket
4// money goes straight to the artist's owner address in the same transaction,
5// the service fee to the GnoRadio treasury: this realm ends every
6// transaction holding no coins.
7//
8// It keeps no state but its admin role. Every record lives in the data realm,
9// in the "tickets/…" collections only the current tickets writer may change
10// (db.gno), and the NFTs in the NFT realm, which only that writer drives: a
11// later release takes over both without copying anything.
12package tickets
13
14import (
15 "chain"
16 "chain/banker"
17 "chain/runtime"
18 "chain/runtime/unsafe"
19 "strconv"
20 "strings"
21 "time"
22
23 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
24 "gno.land/p/nym-alexiscolin000/gnoradio/safe/v0"
25 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
26 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
27 catalog "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
28 "gno.land/r/nym-alexiscolin000/gnoradio/data"
29 "gno.land/r/nym-alexiscolin000/gnoradio/tickets/nft"
30)
31
32const (
33 denom = "ugnot"
34 ugnot = 1_000_000
35 minPrice = 100_000 // 0.1 GNOT
36 maxTicketPrice = 10_000 * ugnot
37 maxCapacity = 10000
38 maxPerBuyer = 4
39 maxPerArtist = 500 // concerts ever created
40 maxOpenAhead = 10 // future, not cancelled concerts per artist
41 checkInWindow = 12 * 3600 // check-in opens 12h before the concert
42 checkInClose = 12 * 3600 // and closes 12h after its start
43 presentFor = 10 * 60 // a Present admits its ticket for 10 minutes
44 maxServiceFee = 10 * ugnot
45 maxOwned = 1000 // tickets one wallet holds
46 maxGifted = 500 // a wallet takes gifts while it holds fewer: gifts never block buying
47 maxScan = 1000 // upcoming index entries one read walks
48 nftPath = "gno.land/r/nym-alexiscolin000/gnoradio/tickets/nft"
49)
50
51// Event is a concert.
52type Event struct {
53 ID int
54 ArtistID int
55 Title string
56 Venue string
57 Link string // optional https link, public
58 Start int64
59 Price int64 // ugnot, 0 = free
60 Capacity int
61 Sold int
62 Cancelled bool
63 Hidden bool
64 Removed string // the moderator's reason while hidden
65}
66
67// Ticket is one NFT ticket.
68type Ticket struct {
69 ID int
70 Event int
71 Serial int
72 Attended bool
73}
74
75var (
76 self string // this realm's path
77 admin *role.Role // the moderator; nil when a release before renounced it
78)
79
80// init takes the admin over from the previous release (tickets/config
81// "admin"), or names the deployer. While this realm is the tickets writer
82// (release 1 at deploy) it also makes the collections.
83func init(cur realm) {
84 self = cur.PkgPath()
85 holder, mirrored := data.Get(cConfig, "admin")
86 if !mirrored {
87 deployer := unsafe.OriginCaller()
88 if deployer == "" && runtime.ChainID() == "dev" {
89 deployer = chain.PackageAddress(self + "/dev") // gno test: a key nobody holds
90 }
91 holder = deployer.String()
92 }
93 if holder != "" {
94 admin = role.New("admin", address(holder))
95 }
96 if data.Writer("tickets") == self {
97 setup(cur, address(holder))
98 }
99}
100
101// ---- concerts ----
102
103// CreateEvent announces a concert by the caller's artist profile.
104// start is "YYYY-MM-DD HH:MM" in UTC; price is in GNOT ("0" for free, "2.5");
105// link is an optional https URL (stream or venue page, public).
106func CreateEvent(cur realm, title, venue, link, start, price string, capacity int) int {
107 caller := userCaller(cur)
108 noPayment()
109 artistID := mustArtistOf(caller)
110 p := store.Pad(artistID)
111 if count(cByArtist, p+"/", p+"0") >= maxPerArtist {
112 panic("tickets: concert limit reached for this artist")
113 }
114 if count(cAhead, p+"/"+store.TimeKey(now()+1), p+"0") >= maxOpenAhead {
115 panic("tickets: an artist can announce " + text.Itoa(maxOpenAhead) + " upcoming concerts at a time")
116 }
117 title, venue, link = strings.TrimSpace(title), strings.TrimSpace(venue), strings.TrimSpace(link)
118 mustText("title", title, 2, 48)
119 mustText("venue", venue, 2, 48)
120 if link != "" && !text.HTTPS(link, 200) {
121 panic("tickets: link must start with https:// (max 200 chars, no spaces)")
122 }
123 when, err := time.Parse("2006-01-02 15:04", strings.TrimSpace(start))
124 if err != nil {
125 panic("tickets: start must look like 2026-11-20 21:00 (UTC)")
126 }
127 if when.Unix() <= now() {
128 panic("tickets: the concert must be in the future")
129 }
130 pr, ok := parseGNOT(price)
131 if !ok || pr > maxTicketPrice || (pr > 0 && pr < minPrice) {
132 panic("tickets: price must be 0 (free) or between 0.1 and 10000 GNOT")
133 }
134 if capacity < 1 || capacity > maxCapacity {
135 panic("tickets: capacity must be between 1 and 10000")
136 }
137 m := meta()
138 e := &Event{ID: metaCount(m, mEvents) + 1, ArtistID: artistID, Title: title, Venue: venue, Link: link,
139 Start: when.Unix(), Price: pr, Capacity: capacity}
140 save(cur, store.Ops{}.Set(cEvents, store.Pad(e.ID), record(e)).Set(cByArtist, p+"/"+store.Pad(e.ID), "").
141 Set(cUpcoming, upKey(e), "").Set(cAhead, aheadKey(e), "").Set(cMeta, "n", store.With(m, mEvents, text.Itoa(e.ID))))
142 chain.Emit("EventCreated", "id", text.Itoa(e.ID), "artist", text.Itoa(artistID), "start", text.Itoa64(e.Start), "price", text.Itoa64(pr))
143 return e.ID
144}
145
146// CancelEvent stops ticket sales. GnoRadio never held the payments: they
147// went straight to the artist, who handles refunds directly. Artist owner
148// only.
149func CancelEvent(cur realm, eventID int) {
150 caller := userCaller(cur)
151 noPayment()
152 e := mustEvent(eventID)
153 if catalog.ArtistOwner(e.ArtistID) != caller {
154 panic("tickets: only the artist can cancel this concert")
155 }
156 e.Cancelled = true
157 save(cur, store.Ops{}.Set(cEvents, store.Pad(e.ID), record(e)).Del(cUpcoming, upKey(e)).Del(cAhead, aheadKey(e)))
158 chain.Emit("EventCancelled", "id", text.Itoa(e.ID))
159}
160
161// BuyTicket mints a ticket to the caller. A paid ticket needs the exact
162// price plus the service fee attached: the price goes straight to the
163// artist, the fee to the GnoRadio treasury.
164func BuyTicket(cur realm, eventID int) int {
165 caller := userCaller(cur)
166 e := getEvent(eventID)
167 if e == nil || e.Hidden {
168 panic("tickets: unknown concert")
169 }
170 owner, verified, shown := catalog.ArtistPayee(e.ArtistID) // mustEvent's check and the payee, one read
171 if !shown {
172 panic("tickets: unknown concert")
173 }
174 if e.Cancelled {
175 panic("tickets: this concert is cancelled")
176 }
177 if now() >= e.Start {
178 panic("tickets: ticket sales are closed")
179 }
180 if e.Sold >= e.Capacity {
181 panic("tickets: sold out")
182 }
183 if e.Price > 0 && (owner == "" || !verified) {
184 panic("tickets: this artist has not verified their profile yet")
185 }
186 buyKey := store.Pad(e.ID) + "/" + caller.String()
187 bought := text.Atoi(get(cBuyers, buyKey))
188 if bought >= maxPerBuyer || (e.Price == 0 && bought >= 1) {
189 panic("tickets: ticket limit reached for this concert (1 free or 4 paid per wallet)")
190 }
191 fee, to := int64(0), address("")
192 if e.Price == 0 {
193 noPayment()
194 } else {
195 if fee = serviceFee(); fee > 0 {
196 to = catalog.Treasury() // serviceFee is 0 while none is set
197 }
198 if paid() != e.Price+fee {
199 panic("tickets: attach exactly " + text.GNOT(e.Price+fee))
200 }
201 }
202
203 m := meta()
204 tk := &Ticket{ID: metaCount(m, mTickets) + 1, Event: e.ID, Serial: e.Sold + 1}
205 k, i := store.Slot(tk.ID, nft.Chunk)
206 ch := store.Rec(ticketRecord(tk))
207 if i > 0 {
208 ch = store.Append(get(cTickets, k), ticketRecord(tk))
209 }
210 e.Sold = tk.Serial
211 ops := store.Ops{}.Set(cEvents, store.Pad(e.ID), record(e)).
212 Set(cBuyers, buyKey, text.Itoa(bought+1)).Set(cMeta, "n", store.With(m, mTickets, text.Itoa(tk.ID))).Set(cTickets, k, ch)
213 save(cur, own(ops, caller, tk.ID, true))
214 nft.Mint(cross(cur), caller, tk.ID)
215 if e.Price > 0 {
216 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
217 bk.SendCoins(cur.Address(), owner, chain.Coins{{Denom: denom, Amount: e.Price}})
218 if fee > 0 {
219 bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: denom, Amount: fee}})
220 }
221 }
222 chain.Emit("TicketBought", "event", text.Itoa(e.ID), "ticket", text.Itoa(tk.ID), "by", caller.String(), "price", text.Itoa64(e.Price), "fee", text.Itoa64(fee))
223 return tk.ID
224}
225
226// TransferTicket gives one of the caller's tickets to someone else. There is
227// no built-in resale market.
228func TransferTicket(cur realm, to address, ticketID int) {
229 caller := userCaller(cur)
230 noPayment()
231 tk := mustTicket(ticketID)
232 if tk.Attended {
233 panic("tickets: a checked-in ticket can no longer be gifted")
234 }
235 if !role.Canonical(to) || to == caller || data.IsGnoRadio(to) || to == cur.Address() {
236 panic("tickets: invalid recipient")
237 }
238 if nft.Holder(ticketID) != caller {
239 panic("tickets: you do not hold this ticket")
240 }
241 if store.Count(get(cOwned, to.String())) >= maxGifted {
242 panic("tickets: this wallet holds " + text.Itoa(maxGifted) + " tickets and takes no more gifts")
243 }
244 save(cur, own(own(nil, caller, ticketID, false), to, ticketID, true))
245 nft.Transfer(cross(cur), caller, to, ticketID)
246 chain.Emit("TicketGifted", "ticket", text.Itoa(ticketID), "from", caller.String(), "to", to.String())
247}
248
249// Present shows a ticket at the door: its holder signs from their own
250// wallet, with the door code the staff's screen shows after scanning the
251// ticket's QR. A QR built from public data (the ticket id and its holder)
252// cannot do that for them, and a stranger with a copied QR gets another
253// code: the holder's Present does not admit them. CheckIn then admits the
254// ticket with that code for 10 minutes. Only while the door is open: from
255// 12h before the concert to 12h after its start.
256func Present(cur realm, ticketID int, code string) {
257 caller := userCaller(cur)
258 noPayment()
259 mustDoorCode(code)
260 tk := mustTicket(ticketID)
261 e := mustEvent(tk.Event)
262 if nft.Holder(ticketID) != caller {
263 panic("tickets: you do not hold this ticket")
264 }
265 mustDoorOpen(e, tk)
266 data.Set(cross(cur), cPresent, store.Pad(ticketID), store.Rec(caller.String(), text.Itoa64(now()), code))
267 chain.Emit("TicketPresented", "ticket", text.Itoa(ticketID), "holder", caller.String())
268}
269
270// CheckIn marks a ticket as used at the door; its holder earns the
271// "I was there" stamp. The concert's artist owner only, while the door is
272// open, and only for a ticket its current holder presented (Present) with
273// this door code in the last 10 minutes.
274func CheckIn(cur realm, ticketID int, code string) {
275 caller := userCaller(cur)
276 noPayment()
277 mustDoorCode(code)
278 tk := mustTicket(ticketID)
279 e := mustEvent(tk.Event)
280 if catalog.ArtistOwner(e.ArtistID) != caller {
281 panic("tickets: only the concert's artist can check tickets in")
282 }
283 mustDoorOpen(e, tk)
284 if at := PresentedAt(ticketID, code); at == 0 || now()-at > presentFor {
285 panic("tickets: the holder must present this ticket from their wallet with this door code first (it counts for 10 minutes)")
286 }
287 tk.Attended = true
288 k, i := store.Slot(tk.ID, nft.Chunk)
289 holder := nft.Holder(ticketID)
290 save(cur, store.Ops{}.Set(cTickets, k, store.With(get(cTickets, k), i, ticketRecord(tk))).
291 Set(cAttended, holder.String(), text.Itoa(Attended(holder)+1)).Del(cPresent, store.Pad(ticketID)))
292 chain.Emit("CheckedIn", "ticket", text.Itoa(ticketID), "holder", holder.String())
293}
294
295// mustDoorCode refuses a door code that is not 4 to 8 digits.
296func mustDoorCode(code string) {
297 if len(code) < 4 || len(code) > 8 {
298 panic("tickets: the door code is 4 to 8 digits")
299 }
300 for i := 0; i < len(code); i++ {
301 if code[i] < '0' || code[i] > '9' {
302 panic("tickets: the door code is 4 to 8 digits")
303 }
304 }
305}
306
307// mustDoorOpen refuses a ticket the door cannot take now.
308func mustDoorOpen(e *Event, tk *Ticket) {
309 if e.Cancelled {
310 panic("tickets: this concert is cancelled")
311 }
312 if tk.Attended {
313 panic("tickets: ticket already checked in")
314 }
315 switch t := now(); {
316 case t < e.Start-checkInWindow:
317 panic("tickets: check-in opens 12h before the concert")
318 case t > e.Start+checkInClose:
319 panic("tickets: this concert is over, check-in is closed")
320 }
321}
322
323// ---- moderation ----
324
325// HideEvent hides or restores a concert. Admin only; hiding takes a public
326// reason (4-200 characters). Tickets stay with their holders.
327func HideEvent(cur realm, eventID int, hidden bool, reason string) {
328 onlyAdmin(cur)
329 e := getEvent(eventID)
330 if e == nil {
331 panic("tickets: unknown concert")
332 }
333 reason = strings.TrimSpace(reason)
334 if hidden {
335 mustText("reason", reason, 4, 200)
336 } else {
337 reason = ""
338 }
339 e.Hidden, e.Removed = hidden, reason
340 ops := store.Ops{}.Set(cEvents, store.Pad(e.ID), record(e))
341 if visible(e) && !e.Cancelled && e.Start > now() {
342 ops = ops.Set(cUpcoming, upKey(e), "")
343 } else {
344 ops = ops.Del(cUpcoming, upKey(e))
345 }
346 save(cur, ops)
347 chain.Emit("Moderated", "kind", "event", "target", text.Itoa(eventID), "hidden", strconv.FormatBool(hidden), "reason", reason, "by", "moderator")
348}
349
350// RefreshArtist re-reads an artist's visibility in the catalog and updates
351// the Upcoming index for its future concerts (at most maxOpenAhead): once
352// moderators hide a profile, its concerts stop taking places in the
353// Upcoming scan; a restored artist's come back. Anyone may call it.
354func RefreshArtist(cur realm, artistID int) {
355 noPayment()
356 p := store.Pad(artistID) + "/"
357 keys, _ := store.Rows(data.Page(cAhead, p+store.TimeKey(now()+1), p+"~", maxOpenAhead, false))
358 reindex(cur, artistID, keys)
359}
360
361// RefreshArtistPast does the same for up to 50 of an artist's past concerts
362// from offset (oldest first; the 500 an artist may have take ten calls), so
363// a hidden profile's past concerts leave the past-concerts index too.
364// Anyone may call it.
365func RefreshArtistPast(cur realm, artistID, offset int) {
366 noPayment()
367 p := store.Pad(artistID) + "/"
368 lo, hi := data.Index(cAhead, p), data.Index(cAhead, p+store.TimeKey(now()+1))
369 if offset < 0 || lo+offset >= hi {
370 panic("tickets: no past concert of this artist there")
371 }
372 n := hi - lo - offset
373 if n > 50 {
374 n = 50
375 }
376 keys, _ := store.Rows(data.PageAt(cAhead, lo+offset, n, false))
377 reindex(cur, artistID, keys)
378}
379
380// reindex re-files an artist's concerts (their cAhead keys) in the Upcoming
381// index by visibility.
382func reindex(cur realm, artistID int, keys []string) {
383 var ops store.Ops
384 for _, k := range keys {
385 e := getEvent(idOf(k))
386 if visible(e) && !e.Cancelled {
387 ops = ops.Set(cUpcoming, upKey(e), "")
388 } else {
389 ops = ops.Del(cUpcoming, upKey(e))
390 }
391 }
392 save(cur, ops)
393 chain.Emit("ArtistRefreshed", "artist", text.Itoa(artistID), "concerts", text.Itoa(len(keys)))
394}
395
396// SetServiceFee sets the per-ticket service fee in ugnot (0..10 GNOT), paid
397// by buyers of paid tickets on top of the price and forwarded to the GnoRadio
398// treasury. Admin only.
399func SetServiceFee(cur realm, ugnot int64) {
400 onlyAdmin(cur)
401 if ugnot < 0 || ugnot > maxServiceFee {
402 panic("tickets: service fee must be between 0 and 10 GNOT")
403 }
404 data.Set(cross(cur), cConfig, "fee", text.Itoa64(ugnot))
405 chain.Emit("ServiceFee", "ugnot", text.Itoa64(ugnot))
406}
407
408// ---- release and admin ----
409
410// Ready tells the data realm that this release is on chain (see
411// data.Propose). Anyone may call it.
412func Ready(cur realm) {
413 noPayment()
414 data.Ready(cross(cur))
415}
416
417// OfferAdmin offers the admin role to an address (offering it to the admin
418// cancels the offer); AcceptAdmin takes it. Each change is mirrored to the
419// data realm, so the next release keeps the admin.
420func OfferAdmin(cur realm, to address) {
421 onlyAdmin(cur)
422 admin.Offer(0, cur, to)
423}
424
425func AcceptAdmin(cur realm) {
426 noPayment()
427 syncAdmin()
428 if admin == nil {
429 panic("tickets: nothing offered to you")
430 }
431 admin.Accept(0, cur)
432 data.Set(cross(cur), cConfig, "admin", admin.Holder().String())
433}
434
435// RenounceAdmin gives the admin role up for good, for this release and the
436// next ones.
437func RenounceAdmin(cur realm) {
438 onlyAdmin(cur)
439 admin.Renounce(0, cur)
440 data.Set(cross(cur), cConfig, "admin", "")
441}
442
443// Render points gnoweb visitors to the GnoRadio site; a retired release says
444// which realm took over.
445func Render(_ string) string {
446 home := strings.TrimPrefix(strings.TrimSuffix(self, "tickets/v1"), "gno.land") + "home/v1"
447 out := "# GnoRadio · tickets\n\nThis contract holds GnoRadio's ticket rules; the tickets are NFTs of [" +
448 strings.TrimPrefix(nftPath, "gno.land") + "](" + strings.TrimPrefix(nftPath, "gno.land") +
449 "). The site lives at [" + home + "](" + home + ").\n"
450 if w := data.Writer("tickets"); w != self {
451 out += "\nThis release is retired: the tickets rules are now `" + w + "`.\n"
452 }
453 return out
454}
455
456// ---- helpers ----
457
458func now() int64 { return time.Now().Unix() }
459
460func userCaller(cur realm) address {
461 if !cur.Previous().IsUserCall() {
462 panic("tickets: call this directly from your wallet")
463 }
464 return cur.Previous().Address()
465}
466
467// adminAddr is the admin: the data realm's mirror is the authority, so a
468// change the previous release made after this one was deployed counts.
469func adminAddr() address {
470 h, ok := data.Get(cConfig, "admin")
471 return role.Mirror(admin, h, ok)
472}
473
474// syncAdmin adopts the mirror when the previous release changed it after
475// this one's init (during the release's delay).
476func syncAdmin() {
477 h, ok := data.Get(cConfig, "admin")
478 admin = role.Adopt(admin, "admin", h, ok)
479}
480
481func onlyAdmin(cur realm) {
482 syncAdmin()
483 if admin == nil || !admin.Is(0, cur) {
484 panic("tickets: admin only")
485 }
486 noPayment()
487}
488
489func noPayment() {
490 if len(unsafe.OriginSend()) != 0 {
491 panic("tickets: this action takes no coins")
492 }
493}
494
495// paid returns the ugnot attached to the call; anything else is refused.
496func paid() int64 {
497 sent := unsafe.OriginSend()
498 if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount <= 0 {
499 panic("tickets: attach a single ugnot amount")
500 }
501 return sent[0].Amount
502}
503
504// serviceFee is the stored fee, 0 while no treasury is set: the fee has nowhere to go, so buyers never pay it.
505func serviceFee() int64 {
506 if v := get(cConfig, "fee"); v != "" && catalog.Treasury().IsValid() {
507 return text.MustAtoi64(v)
508 }
509 return 0
510}
511
512func mustArtistOf(addr address) int {
513 id := catalog.ArtistOf(addr)
514 if id == 0 || !catalog.ArtistVisible(id) {
515 panic("tickets: create your artist profile first")
516 }
517 return id
518}
519
520func mustText(field, s string, min, max int) {
521 if !text.Valid(s, min, max) {
522 panic("tickets: " + field + " must be " + text.Itoa(min) + "-" + text.Itoa(max) +
523 " characters (letters, digits, spaces and . , ' - ! ? : / &)")
524 }
525 if safe.Slur(s) {
526 panic("tickets: " + field + " holds a slur")
527 }
528}
529
530// parseGNOT parses a decimal GNOT amount ("10", "2.5") into ugnot.
531func parseGNOT(s string) (int64, bool) {
532 s = strings.TrimSpace(s)
533 whole, frac, _ := strings.Cut(s, ".")
534 if whole == "" || len(whole) > 9 || len(frac) > 6 || !text.Digits(whole) || (frac != "" && !text.Digits(frac)) {
535 return 0, false
536 }
537 w, _ := strconv.ParseInt(whole, 10, 64)
538 f, _ := strconv.ParseInt(frac+"000000"[len(frac):], 10, 64)
539 return w*ugnot + f, true
540}