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

curation.gno

13.34 Kb · 415 lines
  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}