editorial.gno
10.21 Kb · 302 lines
1package curation
2
3import (
4 "chain"
5 "strconv"
6
7 "gno.land/p/nt/avl/v0"
8 "gno.land/p/nt/seqid/v0"
9 "gno.land/r/samcrew/launchpad/nft/v1"
10)
11
12const (
13 maxFeature = 14 * 24 * 60 * 60 // seconds
14 holdSpan = 24 * 60 * 60
15 maxHold = 30 * 24 * 60 * 60
16)
17
18// What a founder may appeal: the decision on its application, a hold, the
19// collection's verification, or a feature decision.
20var subjects = [...]string{"application", "hold", "verification", "feature"}
21
22// A feature slot exists only when one manager proposed it and another
23// approved it. The admin can clear a slot and can never create one.
24type feature struct {
25 collection string
26 proposer address
27 approver address // empty until approved
28 reasonHash string
29 reasonCID string
30 until int64
31}
32
33// A hold hides a collection from curated views until a time. It changes
34// presentation only: nothing about the collection's tokens is frozen.
35type hold struct {
36 actor address
37 confirmer address // empty until a second manager confirms
38 extended bool // the admin set the term and its reason
39 reasonHash string
40 reasonCID string
41 until int64
42}
43
44type verification struct {
45 verified bool
46 reasonHash string
47 reasonCID string
48 updatedAt int64
49}
50
51type appeal struct {
52 collection string
53 subject string
54 statementHash string
55 statementCID string
56 filedAt int64
57 openKey string // its key in openAppeals; empty once resolved
58 upheld bool
59 reasonHash string
60 reasonCID string
61 resolvedAt int64
62}
63
64var (
65 features avl.Tree // collection -> *feature
66 holds avl.Tree // collection -> *hold
67 verifications avl.Tree // collection -> verification
68 appeals avl.Tree // "<collection>/<subject>" -> *appeal, the latest
69 lastAppeal seqid.ID
70 openAppeals avl.Tree // seqid key -> appeals key, in filing order
71)
72
73func featureOf(collection string) *feature {
74 if f := features.Get(collection); f != nil {
75 return f.(*feature)
76 }
77 return nil
78}
79
80func holdOf(collection string) *hold {
81 if h := holds.Get(collection); h != nil {
82 return h.(*hold)
83 }
84 return nil
85}
86
87func appealOf(key string) *appeal {
88 if a := appeals.Get(key); a != nil {
89 return a.(*appeal)
90 }
91 return nil
92}
93
94// standing reports whether who could act on the collection as a manager now.
95// An unapproved slot's empty approver holds no seat.
96func standing(collection string, who address) bool {
97 return active(who) && !conflicted(nft.GetInfo(collection), who)
98}
99
100// pair enforces the two-manager rule when second completes what first began:
101// two different managers, the first still seated and unconflicted. The second
102// was checked as the caller.
103func pair(collection string, first, second address) {
104 if first == second || !standing(collection, first) {
105 panic("launchpad/curation: a second unconflicted manager is required")
106 }
107}
108
109// running reports a slot that runs, approved by two managers that both keep
110// their standing: whether hidden or not, it is the collection's slot.
111func running(collection string, f *feature) bool {
112 return now() < f.until &&
113 standing(collection, f.proposer) && standing(collection, f.approver)
114}
115
116// ProposeFeature opens a slot of at most 14 days. It features nothing until
117// a second manager approves. It replaces a slot that is not running: one
118// still unapproved, or one whose proposer or approver lost its standing.
119func ProposeFeature(cur realm, collection, reasonHash, reasonCID string, until int64) {
120 who := manager(cur, collection)
121 commitment(reasonHash, reasonCID)
122 at := now()
123 if until <= at || until > at+maxFeature {
124 panic("launchpad/curation: invalid feature term")
125 }
126 if f := featureOf(collection); f != nil && running(collection, f) {
127 panic("launchpad/curation: feature slot running")
128 }
129 features.Set(collection, &feature{collection: collection, proposer: who,
130 reasonHash: reasonHash, reasonCID: reasonCID, until: until})
131 chain.Emit("FeatureProposed", "collection", collection,
132 "manager", who.String(), "until", strconv.FormatInt(until, 10),
133 "reason_hash", reasonHash, "reason_cid", reasonCID)
134}
135
136func ApproveFeature(cur realm, collection string) {
137 who := manager(cur, collection)
138 f := featureOf(collection)
139 if f == nil || f.approver != "" || now() >= f.until {
140 panic("launchpad/curation: no feature slot awaiting approval")
141 }
142 pair(collection, f.proposer, who)
143 f.approver = who
144 chain.Emit("FeatureApproved", "collection", collection,
145 "proposer", f.proposer.String(), "manager", who.String(),
146 "until", strconv.FormatInt(f.until, 10))
147}
148
149// IsFeatured holds only while the slot runs, both of its managers keep their
150// standing and the collection is not hidden.
151func IsFeatured(collection string) bool {
152 f := featureOf(collection)
153 return f != nil && running(collection, f) && !IsHidden(collection)
154}
155
156func ClearFeature(cur realm, collection, reasonHash, reasonCID string) {
157 requireAdmin(cur)
158 commitment(reasonHash, reasonCID)
159 if _, had := features.Remove(collection); !had {
160 panic("launchpad/curation: no feature slot")
161 }
162 chain.Emit("FeatureCleared", "collection", collection,
163 "reason_hash", reasonHash, "reason_cid", reasonCID)
164}
165
166// HideTemporarily hides a collection for 24 hours on one manager's word. The
167// record outlives the hold: no manager can start another until the admin
168// clears it.
169func HideTemporarily(cur realm, collection, reasonHash, reasonCID string) {
170 who := manager(cur, collection)
171 commitment(reasonHash, reasonCID)
172 if holds.Has(collection) {
173 panic("launchpad/curation: a hold is on record until the admin clears it")
174 }
175 until := now() + holdSpan
176 holds.Set(collection, &hold{actor: who, reasonHash: reasonHash,
177 reasonCID: reasonCID, until: until})
178 chain.Emit("HideStarted", "collection", collection,
179 "manager", who.String(), "until", strconv.FormatInt(until, 10),
180 "reason_hash", reasonHash, "reason_cid", reasonCID)
181}
182
183// ConfirmHide lets a second manager keep a running hold for 24 hours from
184// now, once. After that, or once the admin has set the term, only the admin
185// changes it.
186func ConfirmHide(cur realm, collection string) {
187 who := manager(cur, collection)
188 h := holdOf(collection)
189 at := now()
190 if h == nil || h.confirmer != "" || h.extended || at >= h.until {
191 panic("launchpad/curation: no hold awaiting confirmation")
192 }
193 pair(collection, h.actor, who)
194 h.confirmer, h.until = who, at+holdSpan
195 chain.Emit("HideConfirmed", "collection", collection,
196 "manager", who.String(), "until", strconv.FormatInt(h.until, 10))
197}
198
199// ExtendHide sets the term of a recorded hold, running or over, to a time at
200// most 30 days ahead, and replaces its public reason.
201func ExtendHide(cur realm, collection string, until int64, reasonHash, reasonCID string) {
202 requireAdmin(cur)
203 commitment(reasonHash, reasonCID)
204 h := holdOf(collection)
205 at := now()
206 if h == nil || until <= at || until > at+maxHold {
207 panic("launchpad/curation: invalid hold extension")
208 }
209 h.extended, h.until, h.reasonHash, h.reasonCID = true, until, reasonHash, reasonCID
210 chain.Emit("HideExtended", "collection", collection,
211 "until", strconv.FormatInt(until, 10),
212 "reason_hash", reasonHash, "reason_cid", reasonCID)
213}
214
215func ClearHide(cur realm, collection, reasonHash, reasonCID string) {
216 requireAdmin(cur)
217 commitment(reasonHash, reasonCID)
218 if _, had := holds.Remove(collection); !had {
219 panic("launchpad/curation: no hold on record")
220 }
221 chain.Emit("HideCleared", "collection", collection,
222 "reason_hash", reasonHash, "reason_cid", reasonCID)
223}
224
225// IsHidden holds while the hold runs and the manager whose word set its term
226// keeps its standing: the starter, or the confirmer once it confirmed. A term
227// the admin set rests on the admin alone.
228func IsHidden(collection string) bool {
229 h := holdOf(collection)
230 switch {
231 case h == nil || now() >= h.until:
232 return false
233 case h.extended:
234 return true
235 case h.confirmer != "":
236 return standing(collection, h.confirmer)
237 }
238 return standing(collection, h.actor)
239}
240
241// SetVerified is the admin's statement that a collection is what it claims
242// to be. It says nothing about price or future value.
243func SetVerified(cur realm, collection string, value bool, reasonHash, reasonCID string) {
244 requireAdmin(cur)
245 nft.GetInfo(collection)
246 commitment(reasonHash, reasonCID)
247 if IsVerified(collection) == value {
248 panic("launchpad/curation: verification unchanged")
249 }
250 verifications.Set(collection, verification{value, reasonHash, reasonCID, now()})
251 chain.Emit("VerificationChanged", "collection", collection,
252 "verified", strconv.FormatBool(value),
253 "reason_hash", reasonHash, "reason_cid", reasonCID)
254}
255
256func IsVerified(collection string) bool {
257 v := verifications.Get(collection)
258 return v != nil && v.(verification).verified
259}
260
261// Appeal files the founder's objection on one subject. A collection has at
262// most one open appeal per subject; a resolved one can be followed by another.
263func Appeal(cur realm, collection, subject, statementHash, statementCID string) {
264 who := founder(cur, collection)
265 known := false
266 for _, s := range subjects {
267 known = known || s == subject
268 }
269 if !known {
270 panic("launchpad/curation: invalid appeal subject")
271 }
272 commitment(statementHash, statementCID)
273 key := collection + "/" + subject
274 if a := appealOf(key); a != nil && a.openKey != "" {
275 panic("launchpad/curation: appeal already open")
276 }
277 openKey := lastAppeal.Next().String()
278 appeals.Set(key, &appeal{collection: collection, subject: subject,
279 statementHash: statementHash, statementCID: statementCID,
280 filedAt: now(), openKey: openKey})
281 openAppeals.Set(openKey, key)
282 chain.Emit("AppealFiled", "collection", collection, "subject", subject,
283 "founder", who.String(),
284 "statement_hash", statementHash, "statement_cid", statementCID)
285}
286
287// ResolveAppeal records the admin's answer and its public reason. It changes
288// nothing else: the admin acts on an upheld appeal with the ordinary calls.
289func ResolveAppeal(cur realm, collection, subject string, upheld bool, reasonHash, reasonCID string) {
290 requireAdmin(cur)
291 commitment(reasonHash, reasonCID)
292 a := appealOf(collection + "/" + subject)
293 if a == nil || a.openKey == "" {
294 panic("launchpad/curation: no open appeal")
295 }
296 openAppeals.Remove(a.openKey)
297 a.openKey, a.upheld, a.resolvedAt = "", upheld, now()
298 a.reasonHash, a.reasonCID = reasonHash, reasonCID
299 chain.Emit("AppealResolved", "collection", collection, "subject", subject,
300 "upheld", strconv.FormatBool(upheld),
301 "reason_hash", reasonHash, "reason_cid", reasonCID)
302}