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}
416Raw Package Data
Raw JSON data