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

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}