← Back to Realms

Realm

Pearl

gno.land/r/samcrew/launchpad/curation/v1

Overview

Network
pearl-1
Realm Path
gno.land/r/samcrew/launchpad/curation/v1
Exported Functions
39
State Entries
30
Source Files
4
Total Package Entries
97

Exported Functions

39 exported functions

State

30 state entries

Source Code

FILES
curation.gno
go
1// Package curation records who curates the Launchpad marketplace and what
2// they decided about a collection: applications, feature slots, temporary
3// holds, verification and appeals. It holds no coins and no tokens, never
4// changes a collection's terms and never blocks a transfer, sale, refund or
5// claim: a client reads these records and decides what to show.
6package curation
7
8import (
9	"chain"
10	"chain/runtime/unsafe"
11	"strconv"
12	"strings"
13	"time"
14
15	"gno.land/p/nt/avl/v0"
16	"gno.land/p/nt/seqid/v0"
17	"gno.land/p/samcrew/launchpad/currency/v1"
18	"gno.land/p/samcrew/launchpad/meta/v1"
19	"gno.land/r/samcrew/launchpad/nft/v1"
20)
21
22const (
23	maxSeats = 5
24	maxTerm  = 90 * 24 * 60 * 60 // seconds
25
26	StatusSubmitted        = "submitted"
27	StatusChangesRequested = "changes_requested"
28	StatusRecommended      = "recommended"
29	StatusDeclined         = "declined"
30)
31
32// A seat is active strictly before until. Lead is a public label and grants
33// nothing.
34type seat struct {
35	lead  bool
36	until int64
37}
38
39// An Application is a founder's request for review and the latest decision on
40// it. Earlier revisions and decisions stay in the event log.
41type Application struct {
42	Founder       address // who filed the latest revision
43	StatementHash string
44	StatementCID  string
45	Revision      int64 // 0: nobody applied
46	Status        string
47	Reviewer      address
48	ReasonHash    string
49	ReasonCID     string
50	UpdatedAt     int64
51}
52
53var (
54	admin        address
55	pendingAdmin address
56	seats        avl.Tree // manager address -> seat; at most maxSeats entries
57	conflicts    avl.Tree // "<collection>/<address>" -> true; never removed
58	applications avl.Tree // collection -> *Application
59	lastFiling   seqid.ID
60	filings      avl.Tree // seqid key -> collection, in order of first filing
61)
62
63// The account that publishes the package is the first admin.
64func init() { admin = unsafe.OriginCaller() }
65
66func now() int64 { return time.Now().Unix() }
67
68// caller returns the immediate caller. Nothing here takes payment: coins a
69// wallet attaches to a direct call would stay in this realm, so they are
70// refused. A calling realm keeps whatever its own caller sent.
71func caller(cur realm) address {
72	if !cur.IsCurrent() {
73		panic("launchpad/curation: stale realm context")
74	}
75	if cur.Previous().IsUserCall() {
76		currency.AssertNoCoins(0, cur)
77	}
78	return cur.Previous().Address()
79}
80
81// direct returns the wallet that called this realm itself. Founders and
82// managers act in person: no realm can apply, review or recuse for them.
83func direct(cur realm) address {
84	who := caller(cur)
85	if !cur.Previous().IsUserCall() {
86		panic("launchpad/curation: direct user call required")
87	}
88	return who
89}
90
91func requireAdmin(cur realm) {
92	if caller(cur) != admin {
93		panic("launchpad/curation: admin only")
94	}
95}
96
97var zeroHash = strings.Repeat("0", 64)
98
99// commitment checks a pointer to public text: the SHA-256 of its exact bytes
100// as 64 lowercase hex digits, not all zero, and a bounded IPFS CID it can be
101// fetched from (CIDv1 "bafy..." or "bafk..." in base32, or CIDv0 "Qm..." in
102// base58). Clients must hash what they fetch before showing it. Private prose
103// never goes on chain. Byte ranges, not alphabet lookups: this is most of the
104// cost of a call that takes a pointer.
105func commitment(hash, cid string) {
106	ok := len(hash) == 64 && hash != zeroHash
107	for i := 0; ok && i < len(hash); i++ {
108		c := hash[i]
109		ok = c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
110	}
111	switch {
112	case len(cid) >= 59 && len(cid) <= 90 && (cid[:4] == "bafy" || cid[:4] == "bafk"):
113		for i := 4; ok && i < len(cid); i++ {
114			c := cid[i]
115			ok = c >= 'a' && c <= 'z' || c >= '2' && c <= '7'
116		}
117	case len(cid) == 46 && cid[:2] == "Qm":
118		for i := 2; ok && i < len(cid); i++ {
119			c := cid[i]
120			ok = c >= '1' && c <= '9' || c >= 'A' && c <= 'H' || c >= 'J' && c <= 'N' ||
121				c >= 'P' && c <= 'Z' || c >= 'a' && c <= 'k' || c >= 'm' && c <= 'z'
122		}
123	default:
124		ok = false
125	}
126	if !ok {
127		panic("launchpad/curation: invalid hash or CID")
128	}
129}
130
131// ProposeAdmin starts a handoff to any account or realm. Nothing changes
132// until that exact address accepts.
133func ProposeAdmin(cur realm, next address) {
134	requireAdmin(cur)
135	if !meta.ValidAddress(next) || next == admin {
136		panic("launchpad/curation: invalid next admin")
137	}
138	pendingAdmin = next
139	chain.Emit("AdminProposed", "admin", admin.String(), "pending", next.String())
140}
141
142func CancelAdminProposal(cur realm) {
143	requireAdmin(cur)
144	if pendingAdmin == "" {
145		panic("launchpad/curation: no pending admin")
146	}
147	pendingAdmin = ""
148	chain.Emit("AdminProposalCancelled", "admin", admin.String())
149}
150
151// AcceptAdmin completes the handoff. No caller has the empty address, so
152// nobody accepts while nothing is proposed. The admin holds no seat: a
153// manager who accepts gives its seat up.
154func AcceptAdmin(cur realm) {
155	who := caller(cur)
156	if who != pendingAdmin {
157		panic("launchpad/curation: pending admin only")
158	}
159	previous := admin
160	admin, pendingAdmin = who, ""
161	if _, held := seats.Remove(who.String()); held {
162		chain.Emit("ManagerRemoved", "manager", who.String())
163	}
164	chain.Emit("AdminAccepted", "admin", who.String(), "previous", previous.String())
165}
166
167func GetAdmin() address        { return admin }
168func GetPendingAdmin() address { return pendingAdmin }
169
170// checkAccount refuses an address the chain never gives a caller: another
171// spelling of an account would read as a stranger.
172func checkAccount(who address) {
173	if !meta.ValidAddress(who) {
174		panic("launchpad/curation: invalid account")
175	}
176}
177
178func active(who address) bool {
179	s := seats.Get(who.String())
180	return s != nil && now() < s.(seat).until
181}
182
183func ActiveManager(who address) bool {
184	checkAccount(who)
185	return active(who)
186}
187
188func ActiveManagerCount() int {
189	n, at := 0, now()
190	seats.Iterate("", "", func(_ string, v any) bool {
191		if at < v.(seat).until {
192			n++
193		}
194		return false
195	})
196	return n
197}
198
199// AppointManager seats a manager until a time at most 90 days ahead, or
200// replaces the term of one already seated. Expired seats are dropped first,
201// so the tree never holds more than maxSeats entries.
202func AppointManager(cur realm, who address, lead bool, until int64) {
203	requireAdmin(cur)
204	at := now()
205	if !meta.ValidAddress(who) || who == admin || until <= at || until > at+maxTerm {
206		panic("launchpad/curation: invalid manager term")
207	}
208	var expired []string
209	seats.Iterate("", "", func(key string, v any) bool {
210		if v.(seat).until <= at {
211			expired = append(expired, key)
212		}
213		return false
214	})
215	for _, key := range expired {
216		seats.Remove(key)
217	}
218	if !seats.Has(who.String()) && seats.Size() >= maxSeats {
219		panic("launchpad/curation: every seat is taken")
220	}
221	seats.Set(who.String(), seat{lead, until})
222	chain.Emit("ManagerAppointed", "manager", who.String(),
223		"lead", strconv.FormatBool(lead), "until", strconv.FormatInt(until, 10))
224}
225
226func RemoveManager(cur realm, who address) {
227	requireAdmin(cur)
228	if !ActiveManager(who) {
229		panic("launchpad/curation: no active manager")
230	}
231	seats.Remove(who.String())
232	chain.Emit("ManagerRemoved", "manager", who.String())
233}
234
235func conflictKey(collection string, who address) string {
236	return collection + "/" + who.String()
237}
238
239func recorded(collection string, who address) bool {
240	return conflicts.Has(conflictKey(collection, who))
241}
242
243// HasConflict reports a recorded conflict only: a filing, a recusal or an
244// admin's mark. Conflicted is the full rule.
245func HasConflict(collection string, who address) bool {
246	checkAccount(who)
247	return recorded(collection, who)
248}
249
250// Conflicted reports whether an account may not act as a manager on a
251// collection: it created the collection, holds or is proposed for its
252// creator role now, or has a recorded conflict. It aborts on an unknown
253// collection.
254func Conflicted(collection string, who address) bool {
255	checkAccount(who)
256	return conflicted(nft.GetInfo(collection), who)
257}
258
259func conflicted(info nft.Info, who address) bool {
260	return info.Originator == who || info.Creator == who ||
261		info.PendingCreator == who || recorded(info.ID, who)
262}
263
264func seated(cur realm) address {
265	who := direct(cur)
266	if !active(who) {
267		panic("launchpad/curation: active manager only")
268	}
269	return who
270}
271
272// manager returns the calling manager, who must hold an active seat and have
273// no conflict on the collection.
274func manager(cur realm, collection string) address {
275	who := seated(cur)
276	if conflicted(nft.GetInfo(collection), who) {
277		panic("launchpad/curation: conflicted manager")
278	}
279	return who
280}
281
282// Resign gives up the calling manager's own seat at once.
283func Resign(cur realm) {
284	who := seated(cur)
285	seats.Remove(who.String())
286	chain.Emit("ManagerResigned", "manager", who.String())
287}
288
289// reopen voids the decision of a manager found conflicted after making it:
290// the application awaits review again, so a conflict cannot be dodged by
291// deciding first and disclosing later.
292func reopen(collection string, who address) {
293	a := applicationOf(collection)
294	if a != nil && a.Reviewer == who {
295		a.Status, a.Reviewer, a.ReasonHash, a.ReasonCID = StatusSubmitted, "", "", ""
296		a.UpdatedAt = now()
297		chain.Emit("ApplicationReopened", "collection", collection,
298			"revision", strconv.FormatInt(a.Revision, 10), "reason", "conflict")
299	}
300}
301
302// Recuse records the calling manager's own conflict on a collection and voids
303// its decision there, if it made the latest one. A recorded conflict is
304// permanent: nobody, the admin included, can clear it, and recusing again
305// records and emits nothing.
306func Recuse(cur realm, collection string) {
307	who := seated(cur)
308	nft.GetInfo(collection)
309	if !recorded(collection, who) {
310		conflicts.Set(conflictKey(collection, who), true)
311		chain.Emit("ManagerRecused", "collection", collection, "manager", who.String())
312		reopen(collection, who)
313	}
314}
315
316// MarkConflict records a conflict the admin knows of, on a manager or on an
317// account that may become one, and voids that account's latest decision on
318// the collection as Recuse does. Marking a recorded conflict again records
319// and emits nothing.
320func MarkConflict(cur realm, collection string, who address) {
321	requireAdmin(cur)
322	nft.GetInfo(collection)
323	checkAccount(who)
324	if !recorded(collection, who) {
325		conflicts.Set(conflictKey(collection, who), true)
326		chain.Emit("ConflictMarked", "collection", collection, "account", who.String())
327		reopen(collection, who)
328	}
329}
330
331// founder returns the calling wallet, which must hold the collection's
332// creator role.
333func founder(cur realm, collection string) address {
334	who := direct(cur)
335	if nft.GetInfo(collection).Creator != who {
336		panic("launchpad/curation: collection creator only")
337	}
338	return who
339}
340
341func applicationOf(collection string) *Application {
342	if a := applications.Get(collection); a != nil {
343		return a.(*Application)
344	}
345	return nil
346}
347
348// GetApplication returns a copy of a collection's application, the zero value
349// (revision 0) when nobody applied, as ApplicationJSON answers null.
350func GetApplication(collection string) Application {
351	if a := applicationOf(collection); a != nil {
352		return *a
353	}
354	return Application{}
355}
356
357// Apply files the founder's application for a collection. The founder may
358// file again at any time before a recommendation, to correct a pointer or
359// after a manager asked for changes or declined. Each filing is a new
360// revision awaiting review. Filing records the filer's conflict on the
361// collection for good. A realm that holds the creator role never applies: the
362// founder calls in person.
363func Apply(cur realm, collection, statementHash, statementCID string) {
364	who := founder(cur, collection)
365	commitment(statementHash, statementCID)
366	a := applicationOf(collection)
367	if a == nil {
368		a = &Application{}
369		applications.Set(collection, a)
370		filings.Set(lastFiling.Next().String(), collection)
371	} else if a.Status == StatusRecommended {
372		panic("launchpad/curation: application recommended")
373	}
374	a.Founder = who // the creator role may have changed hands since the last filing
375	if !recorded(collection, who) {
376		conflicts.Set(conflictKey(collection, who), true)
377	}
378	a.Revision++
379	a.StatementHash, a.StatementCID = statementHash, statementCID
380	a.Status, a.Reviewer, a.ReasonHash, a.ReasonCID = StatusSubmitted, "", "", ""
381	a.UpdatedAt = now()
382	chain.Emit("ApplicationSubmitted", "collection", collection,
383		"founder", who.String(), "revision", strconv.FormatInt(a.Revision, 10),
384		"statement_hash", statementHash, "statement_cid", statementCID)
385}
386
387// Review records one manager's decision on the revision it read, which must
388// still be the latest. Any unconflicted manager may decide again, whatever the
389// latest decision. A recommendation is advice to the admin and gives the
390// collection nothing by itself.
391func Review(cur realm, collection string, revision int64, status, reasonHash, reasonCID string) {
392	who := manager(cur, collection)
393	a := applicationOf(collection)
394	if a == nil {
395		panic("launchpad/curation: no application open for review")
396	}
397	if revision != a.Revision {
398		panic("launchpad/curation: application changed")
399	}
400	if status != StatusChangesRequested && status != StatusRecommended &&
401		status != StatusDeclined {
402		panic("launchpad/curation: invalid review status")
403	}
404	commitment(reasonHash, reasonCID)
405	a.Status, a.Reviewer, a.ReasonHash, a.ReasonCID = status, who, reasonHash, reasonCID
406	a.UpdatedAt = now()
407	chain.Emit("ApplicationReviewed", "collection", collection,
408		"manager", who.String(), "revision", strconv.FormatInt(a.Revision, 10),
409		"status", status, "reason_hash", reasonHash, "reason_cid", reasonCID)
410}
411
412func Render(_ string) string {
413	return "# Launchpad curation\n\n" + strconv.Itoa(ActiveManagerCount()) +
414		" active managers, " + strconv.Itoa(applications.Size()) + " applications.\n"
415}
416

Raw Package Data

Raw JSON data