Search Apps Documentation Source Content File Folder Download Copy Actions Download

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) }