appstore.gno
10.45 Kb · 317 lines
1// Package memba_appstore_v2 is a curated App Store for gno.land dApps: publishers
2// pay a flat listing fee to register an app; a curator flips it live.
3//
4// MONEY PATH (the only one): RegisterApp collects a flat `registrationFee` in ugnot
5// and forwards 100% to the treasury in the SAME call — nothing is ever custodied.
6// The safety contract mirrors memba_token_otc_v1 + the O-13 lesson:
7// 1. IsUserCall() guard BEFORE reading OriginSend — an ephemeral `maketx run`
8// realm can never attach unrecoverable coins (the guard agent_registry missed).
9// 2. exact-coin via unsafe.OriginSend() (the coins on THIS call, not the wallet
10// balance) — closes the overpay-trap and the wallet-balance bypass.
11// 3. treasury-misconfig is fail-closed: an unset treasury panics (→ tx reverts →
12// coins refunded), never silent custody.
13// 4. CEI: state is written before the banker moves funds.
14// 5. NewBanker(RealmSend, cur) sends the fee from the realm's own address to the
15// treasury — no custody, no escrow (which is why no escrow is needed to be safe).
16//
17// TREASURY: stored LOCALLY (admin-settable, 2-step handoff, defaults to the samcrew
18// multisig) rather than read from memba_market_config — this keeps the realm
19// self-contained so the whole money path is unit-tested locally. Keep it in sync with
20// memba_market_config.GetTreasury(); a future version may read the fee spine directly
21// (which makes the realm non-locally-testable — a deliberate later trade-off).
22package memba_appstore_v2
23
24import (
25 "chain"
26 "chain/banker"
27 "chain/runtime"
28 "chain/runtime/unsafe"
29
30 "gno.land/p/nt/avl/v0"
31 "gno.land/p/nt/ufmt/v0"
32)
33
34// AdminAddress is the samcrew-core 2-of-2 multisig (same as the fee spine's admin/
35// treasury). It is the initial owner AND the initial treasury.
36const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
37
38const (
39 // DefaultRegistrationFee is the launch listing fee: 1 GNOT (1_000_000 ugnot).
40 // The DAO/admin can change it via SetRegistrationFee (bounded by MaxRegistrationFee).
41 DefaultRegistrationFee = int64(1_000_000)
42 // MaxRegistrationFee caps a fat-finger / compromised-proposal fee at 100 GNOT.
43 MaxRegistrationFee = int64(100_000_000)
44
45 MaxNameLen = 80
46 MaxTaglineLen = 140
47 MaxDescrLen = 2000
48 MaxURLLen = 400
49 MaxPkgPathLen = 200
50 MaxCIDLen = 100
51 // FlagHideThreshold auto-hides a listing from the live list once this many
52 // distinct addresses have flagged it (a curator can DelistApp / restore).
53 FlagHideThreshold = 5
54)
55
56// StatusPending / StatusLive / StatusDelisted are the listing lifecycle states.
57const (
58 StatusPending = "pending"
59 StatusLive = "live"
60 StatusDelisted = "delisted"
61)
62
63// Listing is one app. PkgPath (the realm/package path) is the unique key.
64type Listing struct {
65 Id uint64
66 PkgPath string
67 Name string
68 Tagline string
69 Descr string
70 Category string
71 IconCID string
72 AppURL string
73 Publisher address
74 Status string
75 FlagCount int
76 CreatedAt int64
77}
78
79var (
80 owner address
81 pendingOwner address
82 treasury address
83 registrationFee int64
84 paused bool
85 curators = avl.NewTree() // address string -> bool
86 listings = avl.NewTree() // pkgPath -> *Listing
87 flaggedBy = avl.NewTree() // pkgPath + "\x00" + addr -> bool (one flag per addr)
88 nextId uint64 = 1
89)
90
91func init() {
92 owner = address(AdminAddress)
93 treasury = address(AdminAddress)
94 registrationFee = DefaultRegistrationFee
95 curators.Set(AdminAddress, true) // the admin is a curator by default
96}
97
98func caller() address { return unsafe.PreviousRealm().Address() }
99
100func assertOwner() {
101 if caller() != owner {
102 panic("unauthorized: owner only")
103 }
104}
105
106func assertNotPaused() {
107 if paused {
108 panic("appstore is paused")
109 }
110}
111
112// ── Money path ────────────────────────────────────────────────────────────────
113
114// RegisterApp lists a new app. The caller pays EXACTLY registrationFee ugnot with
115// the call; the whole fee is forwarded to the treasury (no custody). The listing
116// starts `pending` and is not shown live until a curator approves it.
117func RegisterApp(
118 cur realm,
119 pkgPath, name, tagline, descr, category, iconCID, appURL string,
120) uint64 {
121 assertNotPaused()
122
123 // O-13 guard: a payable entrypoint MUST be a direct user call, so an ephemeral
124 // `maketx run` realm can never attach coins that become unrecoverable.
125 if !unsafe.PreviousRealm().IsUserCall() {
126 panic("RegisterApp must be a direct user call")
127 }
128
129 pkgPath = validatePkgPath(pkgPath)
130 if _, dup := listings.Get(pkgPath); dup {
131 panic("app already registered for this package path")
132 }
133 if len(name) == 0 || len(name) > MaxNameLen {
134 panic("name must be 1.." + itoa(MaxNameLen) + " chars")
135 }
136 if len(tagline) > MaxTaglineLen {
137 panic("tagline too long")
138 }
139 if len(descr) > MaxDescrLen {
140 panic("description too long")
141 }
142 if len(appURL) > MaxURLLen {
143 panic("appURL too long")
144 }
145 if len(iconCID) > MaxCIDLen {
146 panic("iconCID too long")
147 }
148
149 // Exact-coin: read the coins attached to THIS call (not the wallet balance).
150 sent := unsafe.OriginSend()
151 if sent.AmountOf("ugnot") != registrationFee {
152 panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", registrationFee, sent.AmountOf("ugnot")))
153 }
154 // Fail-closed on a misconfigured treasury — reverting refunds the coins; we never
155 // keep custody of a fee we can't route.
156 if treasury == "" {
157 panic("treasury unset — registration disabled")
158 }
159
160 // CEI: write state before moving funds.
161 id := nextId
162 nextId++
163 l := &Listing{
164 Id: id,
165 PkgPath: pkgPath,
166 Name: name,
167 Tagline: tagline,
168 Descr: descr,
169 Category: category,
170 IconCID: iconCID,
171 AppURL: appURL,
172 Publisher: caller(),
173 Status: StatusPending,
174 CreatedAt: runtime.ChainHeight(),
175 }
176 listings.Set(pkgPath, l)
177
178 // Forward the whole fee to the treasury from the realm's own account.
179 if registrationFee > 0 {
180 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
181 bnk.SendCoins(
182 unsafe.CurrentRealm().Address(),
183 treasury,
184 chain.Coins{chain.NewCoin("ugnot", registrationFee)},
185 )
186 }
187
188 chain.Emit("AppRegistered", "pkgPath", pkgPath, "id", itoa64(id), "publisher", caller().String())
189 return id
190}
191
192// ── Curation & moderation ───────────────────────────────────────────────────
193
194// ApproveApp flips a pending listing live. Curator-only.
195func ApproveApp(cur realm, pkgPath string) {
196 if !isCurator(caller()) {
197 panic("unauthorized: curator only")
198 }
199 l := mustGet(pkgPath)
200 if l.Status == StatusDelisted {
201 panic("cannot approve a delisted app")
202 }
203 l.Status = StatusLive
204 listings.Set(pkgPath, l)
205 chain.Emit("AppApproved", "pkgPath", pkgPath)
206}
207
208// DelistApp removes a listing from public view. The publisher or a curator may do it.
209func DelistApp(cur realm, pkgPath string) {
210 l := mustGet(pkgPath)
211 if caller() != l.Publisher && !isCurator(caller()) {
212 panic("unauthorized: publisher or curator only")
213 }
214 l.Status = StatusDelisted
215 listings.Set(pkgPath, l)
216 chain.Emit("AppDelisted", "pkgPath", pkgPath)
217}
218
219// RestoreApp brings a delisted app back to `pending` (re-curation required before it
220// shows live again). Curator-only — this is the reverse of DelistApp, so a mistaken
221// or contested delist is recoverable.
222func RestoreApp(cur realm, pkgPath string) {
223 if !isCurator(caller()) {
224 panic("unauthorized: curator only")
225 }
226 l := mustGet(pkgPath)
227 if l.Status != StatusDelisted {
228 panic("only a delisted app can be restored")
229 }
230 l.Status = StatusPending
231 listings.Set(pkgPath, l)
232 chain.Emit("AppRestored", "pkgPath", pkgPath)
233}
234
235// FlagApp lets any user flag a LIVE listing once. At FlagHideThreshold distinct flags
236// the listing stops appearing live; a curator then DelistApp's it or leaves it for
237// review (a delisted app can be brought back with RestoreApp).
238func FlagApp(cur realm, pkgPath string) {
239 l := mustGet(pkgPath)
240 if l.Status != StatusLive {
241 panic("can only flag a live app")
242 }
243 key := pkgPath + "\x00" + caller().String()
244 if _, done := flaggedBy.Get(key); done {
245 panic("already flagged")
246 }
247 flaggedBy.Set(key, true)
248 l.FlagCount++
249 listings.Set(pkgPath, l)
250 chain.Emit("AppFlagged", "pkgPath", pkgPath, "count", itoa(l.FlagCount))
251}
252
253// isVisible reports whether a listing shows in the live list.
254func isVisible(l *Listing) bool {
255 return l.Status == StatusLive && l.FlagCount < FlagHideThreshold
256}
257
258// ── Read getters (pure, non-failing) ─────────────────────────────────────────
259
260// GetRegistrationFee returns the current flat listing fee in ugnot.
261func GetRegistrationFee() int64 { return registrationFee }
262
263// GetTreasury returns the address that receives listing fees.
264func GetTreasury() address { return treasury }
265
266// GetOwner returns the current owner (multisig, or a DAO executor after handoff).
267func GetOwner() address { return owner }
268
269// AppCount returns the total number of registered listings (any status).
270func AppCount() int { return listings.Size() }
271
272// GetStatsJSON returns a small stats object for a status header.
273func GetStatsJSON() string {
274 live := 0
275 listings.Iterate("", "", func(_ string, v any) bool {
276 if isVisible(v.(*Listing)) {
277 live++
278 }
279 return false
280 })
281 return ufmt.Sprintf(`{"total":%d,"live":%d,"registrationFee":%d,"paused":%t}`,
282 listings.Size(), live, registrationFee, paused)
283}
284
285// ── internal helpers ─────────────────────────────────────────────────────────
286
287func isCurator(a address) bool {
288 _, ok := curators.Get(a.String())
289 return ok
290}
291
292func mustGet(pkgPath string) *Listing {
293 v, ok := listings.Get(pkgPath)
294 if !ok {
295 panic("app not found: " + pkgPath)
296 }
297 return v.(*Listing)
298}
299
300// validatePkgPath normalizes + sanity-checks a realm/package path.
301func validatePkgPath(p string) string {
302 if len(p) == 0 || len(p) > MaxPkgPathLen {
303 panic("pkgPath must be 1.." + itoa(MaxPkgPathLen) + " chars")
304 }
305 // Must be a gno.land realm ("/r/") or package ("/p/") path.
306 if !hasPrefix(p, "gno.land/r/") && !hasPrefix(p, "gno.land/p/") {
307 panic("pkgPath must be a gno.land/r/... or gno.land/p/... path")
308 }
309 return p
310}
311
312func hasPrefix(s, pre string) bool {
313 return len(s) >= len(pre) && s[:len(pre)] == pre
314}
315
316func itoa(n int) string { return ufmt.Sprintf("%d", n) }
317func itoa64(n uint64) string { return ufmt.Sprintf("%d", n) }