Search Apps Documentation Source Content File Folder Download Copy Actions Download

appstore.gno

22.22 Kb · 648 lines
  1// Package memba_appstore_v3 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 (or rejects it).
  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. All attacker-controlled
 14//      input (screenshots, appURL scheme) is validated BEFORE OriginSend is read.
 15//   5. NewBanker(RealmSend, cur) sends the fee from the realm's own address to the
 16//      treasury — no custody, no escrow (which is why no escrow is needed to be safe).
 17//
 18// v3 over v2: a `rejected` state + RejectApp/EditListing lifecycle, ≤6 screenshots,
 19// an on-chain appURL scheme allowlist, FlagApp extended to pending listings (the
 20// public Unverified tab's safety valve), composite-key status/publisher indexes with
 21// O(1) per-status counters (so status/publisher reads are bounded, not full scans),
 22// and a sealed SeedListing migration primitive (FinalizeSeed closes the backdoor).
 23//
 24// TREASURY: stored LOCALLY (admin-settable, 2-step handoff, defaults to the samcrew
 25// multisig) rather than read from memba_market_config — keeps the money path locally
 26// unit-testable. Keep it in sync with memba_market_config.GetTreasury().
 27package memba_appstore_v3
 28
 29import (
 30	"strings"
 31
 32	"chain"
 33	"chain/banker"
 34	"chain/runtime"
 35	"chain/runtime/unsafe"
 36
 37	"gno.land/p/nt/avl/v0"
 38	"gno.land/p/nt/ufmt/v0"
 39)
 40
 41// AdminAddress is the samcrew-core 2-of-2 multisig (same as the fee spine's admin/
 42// treasury). It is the initial owner AND the initial treasury.
 43const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
 44
 45const (
 46	// DefaultRegistrationFee is the launch listing fee: 1 GNOT (1_000_000 ugnot).
 47	DefaultRegistrationFee = int64(1_000_000)
 48	// MaxRegistrationFee caps a fat-finger / compromised-proposal fee at 100 GNOT.
 49	MaxRegistrationFee = int64(100_000_000)
 50
 51	MaxNameLen     = 80
 52	MaxTaglineLen  = 140
 53	MaxDescrLen    = 2000
 54	MaxCategoryLen = 40
 55	MaxURLLen      = 400
 56	MaxPkgPathLen  = 200
 57	MaxCIDLen      = 100
 58	MaxReasonLen   = 500
 59	// MaxScreenshots bounds the per-listing screenshot gallery.
 60	MaxScreenshots = 6
 61	// MaxResubmits bounds how many times a publisher can edit/resubmit a listing, so a
 62	// reject→edit→pending loop can't grief the curator queue indefinitely.
 63	MaxResubmits = 5
 64	// FlagHideThreshold auto-hides a listing (live OR pending) from the public lists once
 65	// this many distinct addresses have flagged it.
 66	FlagHideThreshold = 5
 67)
 68
 69// Listing lifecycle states.
 70const (
 71	StatusPending  = "pending"
 72	StatusLive     = "live"
 73	StatusRejected = "rejected"
 74	StatusDelisted = "delisted"
 75)
 76
 77// Listing is one app. PkgPath (the realm/package path) is the unique key.
 78type Listing struct {
 79	Id                 uint64
 80	PkgPath            string
 81	Name               string
 82	Tagline            string
 83	Descr              string
 84	Category           string
 85	IconCID            string
 86	ScreenshotCIDs     []string
 87	AppURL             string
 88	Publisher          address
 89	Status             string
 90	RejectReason       string
 91	PaidResubmitCredit bool
 92	ResubmitCount      int
 93	FlagCount          int
 94	CreatedAt          int64
 95}
 96
 97var (
 98	owner          address
 99	pendingOwner   address
100	treasury       address
101	registrationFee int64
102	paused         bool
103	seedingSealed  bool
104	curators       = avl.NewTree() // address string -> bool
105	listings       = avl.NewTree() // pkgPath -> *Listing
106	flaggedBy      = avl.NewTree() // pkgPath + "\x00" + addr -> bool (one flag per addr)
107	// statusIndex/publisherIndex: composite-key indexes for bounded status/publisher reads.
108	// key = status|pub + "\x00" + zeroPad(id) -> pkgPath, so a prefix range-iterate yields a
109	// true O(offset+limit) window ordered by submission id, never a full-catalog scan.
110	statusIndex    = avl.NewTree()
111	publisherIndex = avl.NewTree()
112	// O(1) per-status counters, maintained on every status transition.
113	liveCount     int
114	pendingCount  int
115	rejectedCount int
116	delistedCount int
117	nextId uint64 = 1
118)
119
120func init() {
121	owner = address(AdminAddress)
122	treasury = address(AdminAddress)
123	registrationFee = DefaultRegistrationFee
124	curators.Set(AdminAddress, true) // the admin is a curator by default
125}
126
127func caller() address { return unsafe.PreviousRealm().Address() }
128
129func assertOwner() {
130	if caller() != owner {
131		panic("unauthorized: owner only")
132	}
133}
134
135func assertNotPaused() {
136	if paused {
137		panic("appstore is paused")
138	}
139}
140
141// ── Money path ────────────────────────────────────────────────────────────────
142
143// RegisterApp lists a new app. The caller pays EXACTLY registrationFee ugnot with the call;
144// the whole fee is forwarded to the treasury (no custody). The listing starts `pending`.
145func RegisterApp(
146	cur realm,
147	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string,
148) uint64 {
149	assertNotPaused()
150
151	// O-13 guard: a payable entrypoint MUST be a direct user call.
152	if !unsafe.PreviousRealm().IsUserCall() {
153		panic("RegisterApp must be a direct user call")
154	}
155
156	// Validate ALL attacker-controlled input BEFORE reading the attached coins.
157	pkgPath = validatePkgPath(pkgPath)
158	if _, dup := listings.Get(pkgPath); dup {
159		panic("app already registered for this package path")
160	}
161	validateListingFields(name, tagline, descr, category, iconCID, appURL)
162	shots := parseScreenshots(screenshotsCSV)
163
164	// Exact-coin: read the coins attached to THIS call (not the wallet balance).
165	sent := unsafe.OriginSend()
166	if sent.AmountOf("ugnot") != registrationFee {
167		panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", registrationFee, sent.AmountOf("ugnot")))
168	}
169	// Fail-closed on a misconfigured treasury — reverting refunds the coins.
170	if treasury == "" {
171		panic("treasury unset — registration disabled")
172	}
173
174	// CEI: write state before moving funds.
175	id := nextId
176	nextId++
177	l := &Listing{
178		Id:             id,
179		PkgPath:        pkgPath,
180		Name:           name,
181		Tagline:        tagline,
182		Descr:          descr,
183		Category:       category,
184		IconCID:        iconCID,
185		ScreenshotCIDs: shots,
186		AppURL:         appURL,
187		Publisher:      caller(),
188		Status:         StatusPending,
189		CreatedAt:      runtime.ChainHeight(),
190	}
191	listings.Set(pkgPath, l)
192	indexInsert(l)
193
194	// Forward the whole fee to the treasury from the realm's own account.
195	if registrationFee > 0 {
196		bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
197		bnk.SendCoins(
198			unsafe.CurrentRealm().Address(),
199			treasury,
200			chain.Coins{chain.NewCoin("ugnot", registrationFee)},
201		)
202	}
203
204	chain.Emit("AppRegistered", "pkgPath", pkgPath, "id", itoa64(id), "publisher", caller().String())
205	return id
206}
207
208// ── Curation & moderation ───────────────────────────────────────────────────
209
210// ApproveApp flips a pending (or previously-live) listing live. Curator-only.
211func ApproveApp(cur realm, pkgPath string) {
212	if !isCurator(caller()) {
213		panic("unauthorized: curator only")
214	}
215	l := mustGet(pkgPath)
216	if l.Status == StatusDelisted {
217		panic("cannot approve a delisted app")
218	}
219	if l.Status != StatusLive {
220		changeStatus(l, StatusLive)
221	}
222	chain.Emit("AppApproved", "pkgPath", pkgPath)
223}
224
225// RejectApp declines a pending submission, recording a reason and granting a one-time free
226// resubmit credit. Curator-only; only a pending app can be rejected.
227func RejectApp(cur realm, pkgPath, reason string) {
228	if !isCurator(caller()) {
229		panic("unauthorized: curator only")
230	}
231	if len(reason) > MaxReasonLen {
232		panic("reason too long")
233	}
234	l := mustGet(pkgPath)
235	if l.Status != StatusPending {
236		panic("only a pending app can be rejected")
237	}
238	changeStatus(l, StatusRejected)
239	l.RejectReason = reason
240	l.PaidResubmitCredit = true
241	listings.Set(pkgPath, l)
242	chain.Emit("AppRejected", "pkgPath", pkgPath)
243}
244
245// EditListing lets the publisher update a listing that is NOT live/delisted — i.e. a pending
246// or rejected one — and resets it to `pending` for (re-)review. Editing a live listing is
247// structurally forbidden so a Verified badge can never be bait-and-switched. Bounded by
248// MaxResubmits so a reject→edit loop can't grief the queue.
249func EditListing(
250	cur realm,
251	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string,
252) {
253	l := mustGet(pkgPath)
254	if caller() != l.Publisher {
255		panic("unauthorized: publisher only")
256	}
257	if l.Status != StatusPending && l.Status != StatusRejected {
258		panic("cannot edit a live or delisted listing")
259	}
260	if l.ResubmitCount >= MaxResubmits {
261		panic("resubmit limit reached")
262	}
263	validateListingFields(name, tagline, descr, category, iconCID, appURL)
264	shots := parseScreenshots(screenshotsCSV)
265
266	l.Name = name
267	l.Tagline = tagline
268	l.Descr = descr
269	l.Category = category
270	l.IconCID = iconCID
271	l.ScreenshotCIDs = shots
272	l.AppURL = appURL
273	l.RejectReason = ""
274	l.ResubmitCount++
275	if l.Status != StatusPending {
276		changeStatus(l, StatusPending) // rejected → pending (re-review); persists via Set
277	} else {
278		listings.Set(pkgPath, l)
279	}
280	chain.Emit("AppEdited", "pkgPath", pkgPath)
281}
282
283// DelistApp removes a listing from public view. The publisher or a curator may do it.
284func DelistApp(cur realm, pkgPath string) {
285	l := mustGet(pkgPath)
286	if caller() != l.Publisher && !isCurator(caller()) {
287		panic("unauthorized: publisher or curator only")
288	}
289	if l.Status != StatusDelisted {
290		changeStatus(l, StatusDelisted)
291	}
292	chain.Emit("AppDelisted", "pkgPath", pkgPath)
293}
294
295// RestoreApp brings a delisted app back to `pending` (re-curation required). Curator-only.
296func RestoreApp(cur realm, pkgPath string) {
297	if !isCurator(caller()) {
298		panic("unauthorized: curator only")
299	}
300	l := mustGet(pkgPath)
301	if l.Status != StatusDelisted {
302		panic("only a delisted app can be restored")
303	}
304	changeStatus(l, StatusPending)
305	chain.Emit("AppRestored", "pkgPath", pkgPath)
306}
307
308// FlagApp lets any user flag a publicly-listed (live OR pending) listing once. At
309// FlagHideThreshold distinct flags the listing drops from the public lists (isVisible), giving
310// the public Unverified/pending tab a community safety valve; a curator can then Delist/Reject.
311func FlagApp(cur realm, pkgPath string) {
312	l := mustGet(pkgPath)
313	if l.Status != StatusLive && l.Status != StatusPending {
314		panic("can only flag a live or pending app")
315	}
316	key := pkgPath + "\x00" + caller().String()
317	if _, done := flaggedBy.Get(key); done {
318		panic("already flagged")
319	}
320	flaggedBy.Set(key, true)
321	l.FlagCount++
322	listings.Set(pkgPath, l)
323	chain.Emit("AppFlagged", "pkgPath", pkgPath, "count", itoa(l.FlagCount))
324}
325
326// MaxClearBatch bounds how many per-address flag marks one ClearFlags call removes,
327// so a mega-brigade (thousands of sybil flags) cannot gas-lock the reset — the
328// curator just calls ClearFlags repeatedly until the count reaches zero.
329const MaxClearBatch = 200
330
331// ClearFlags resets a listing's community-flag state after curator review. Curator-only.
332// Without it a flag-hidden listing stays hidden FOREVER: FlagCount never decrements and
333// survives every status transition, so FlagHideThreshold (5) sybil addresses could
334// permanently disappear any live app. Clearing also deletes the per-address dedupe marks —
335// the community can re-flag if the concern is real, and every clear is an emitted event,
336// so a curator whitewashing a bad listing is publicly visible on-chain.
337func ClearFlags(cur realm, pkgPath string) {
338	if !isCurator(caller()) {
339		panic("unauthorized: curator only")
340	}
341	l := mustGet(pkgPath)
342	if l.FlagCount == 0 {
343		panic("no flags to clear")
344	}
345	// Collect up to MaxClearBatch marks over the listing's prefix range (the same
346	// [prefix+"\x00", prefix+"\x01") window idiom as reads.gno), then remove them.
347	// Keys are collected BEFORE removal — never mutate a tree mid-Iterate.
348	var keys []string
349	flaggedBy.Iterate(pkgPath+"\x00", pkgPath+"\x01", func(k string, _ any) bool {
350		keys = append(keys, k)
351		return len(keys) >= MaxClearBatch
352	})
353	for _, k := range keys {
354		flaggedBy.Remove(k)
355	}
356	l.FlagCount -= len(keys)
357	if l.FlagCount < 0 {
358		l.FlagCount = 0
359	}
360	listings.Set(pkgPath, l)
361	chain.Emit("AppFlagsCleared", "pkgPath", pkgPath,
362		"cleared", itoa(len(keys)), "remaining", itoa(l.FlagCount))
363}
364
365// ── Migration (owner-only, sealable) ──────────────────────────────────────────
366
367// SeedListing imports a listing verbatim (Id, CreatedAt, FlagCount, Status, Publisher) during a
368// v2→v3 migration. Owner-only, NON-payable (never reads OriginSend / moves funds), dedupe-guarded.
369// After the migration the owner calls FinalizeSeed, permanently sealing this entrypoint — without
370// that latch it would be a standing backdoor to forge fee-free listings with arbitrary publisher.
371func SeedListing(
372	cur realm,
373	id uint64,
374	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL, publisherStr, status string,
375	flagCount int,
376	createdAt int64,
377) {
378	assertOwner()
379	if seedingSealed {
380		panic("seeding sealed — migration is finalized")
381	}
382	pkgPath = validatePkgPath(pkgPath)
383	if _, dup := listings.Get(pkgPath); dup {
384		panic("app already registered for this package path")
385	}
386	if !validStatus(status) {
387		panic("invalid status")
388	}
389	// A duplicate id would collide on the composite index key (statusKey/pubKey), silently
390	// overwriting one entry while double-counting — so reject an id already present in ANY status
391	// (covers both a repeat seed and a seeded id that a prior RegisterApp already used).
392	if idInUse(id) {
393		panic("id already in use")
394	}
395	// Seeded data is historical user input — validate it against the SAME field + appURL-scheme
396	// rules as a fresh registration, so an unsafe v2 appURL can't be imported past the allowlist.
397	validateListingFields(name, tagline, descr, category, iconCID, appURL)
398	shots := parseScreenshots(screenshotsCSV)
399	l := &Listing{
400		Id:             id,
401		PkgPath:        pkgPath,
402		Name:           name,
403		Tagline:        tagline,
404		Descr:          descr,
405		Category:       category,
406		IconCID:        iconCID,
407		ScreenshotCIDs: shots,
408		AppURL:         appURL,
409		Publisher:      address(publisherStr),
410		Status:         status,
411		FlagCount:      flagCount,
412		CreatedAt:      createdAt,
413	}
414	listings.Set(pkgPath, l)
415	indexInsert(l)
416	if id >= nextId {
417		nextId = id + 1
418	}
419	chain.Emit("AppSeeded", "pkgPath", pkgPath, "id", itoa64(id))
420}
421
422// FinalizeSeed permanently seals SeedListing (one-way latch). Owner-only.
423func FinalizeSeed(cur realm) {
424	assertOwner()
425	seedingSealed = true
426	chain.Emit("SeedingFinalized")
427}
428
429// ── Read getters (pure, non-failing) ─────────────────────────────────────────
430
431// GetRegistrationFee returns the current flat listing fee in ugnot.
432func GetRegistrationFee() int64 { return registrationFee }
433
434// GetTreasury returns the address that receives listing fees.
435func GetTreasury() address { return treasury }
436
437// GetOwner returns the current owner (multisig, or a DAO executor after handoff).
438func GetOwner() address { return owner }
439
440// AppCount returns the total number of registered listings (any status).
441func AppCount() int { return listings.Size() }
442
443// IsCurator reports whether an address may approve/reject listings (the curator-dashboard gate).
444func IsCurator(a string) bool {
445	_, ok := curators.Get(a)
446	return ok
447}
448
449// GetCuratorsJSON returns a JSON array of curator addresses (small, bounded).
450func GetCuratorsJSON() string {
451	var sb strings.Builder
452	sb.WriteString("[")
453	first := true
454	curators.Iterate("", "", func(k string, _ any) bool {
455		if !first {
456			sb.WriteString(",")
457		}
458		first = false
459		sb.WriteString(`"`)
460		sb.WriteString(k) // a bech32 address — no JSON metachars
461		sb.WriteString(`"`)
462		return false
463	})
464	sb.WriteString("]")
465	return sb.String()
466}
467
468// GetStatsJSON returns per-status counts (served from O(1) counters) for the store header.
469func GetStatsJSON() string {
470	return ufmt.Sprintf(
471		`{"total":%d,"live":%d,"pending":%d,"rejected":%d,"delisted":%d,"registrationFee":%d,"paused":%t}`,
472		listings.Size(), liveCount, pendingCount, rejectedCount, delistedCount, registrationFee, paused)
473}
474
475// ── internal helpers ─────────────────────────────────────────────────────────
476
477func isCurator(a address) bool {
478	_, ok := curators.Get(a.String())
479	return ok
480}
481
482func mustGet(pkgPath string) *Listing {
483	v, ok := listings.Get(pkgPath)
484	if !ok {
485		panic("app not found: " + pkgPath)
486	}
487	return v.(*Listing)
488}
489
490func validStatus(s string) bool {
491	return s == StatusPending || s == StatusLive || s == StatusRejected || s == StatusDelisted
492}
493
494// validateListingFields checks the length + appURL-scheme invariants shared by RegisterApp and
495// EditListing. The appURL scheme allowlist (http/https/leading-slash/empty) is the on-chain
496// defense behind the frontend AppLink — it blocks javascript:/data:/other-scheme phishing URLs.
497func validateListingFields(name, tagline, descr, category, iconCID, appURL string) {
498	if len(name) == 0 || len(name) > MaxNameLen {
499		panic("name must be 1.." + itoa(MaxNameLen) + " chars")
500	}
501	if len(tagline) > MaxTaglineLen {
502		panic("tagline too long")
503	}
504	if len(descr) > MaxDescrLen {
505		panic("description too long")
506	}
507	if len(category) > MaxCategoryLen {
508		panic("category too long")
509	}
510	if len(iconCID) > MaxCIDLen {
511		panic("iconCID too long")
512	}
513	if len(appURL) > MaxURLLen {
514		panic("appURL too long")
515	}
516	validateAppURL(appURL)
517}
518
519// validateAppURL enforces the scheme allowlist: empty, http://, https://, or a leading-slash
520// in-app path. Anything else (javascript:, data:, ftp:, mailto:, …) aborts. A leading-slash path
521// must NOT be protocol-relative (`//host` or `/\host`) — browsers navigate those off-site, which
522// would defeat the allowlist.
523func validateAppURL(u string) {
524	if u == "" {
525		return
526	}
527	if hasPrefix(u, "http://") || hasPrefix(u, "https://") {
528		return
529	}
530	if u[0] == '/' {
531		if len(u) > 1 && (u[1] == '/' || u[1] == '\\') {
532			panic("appURL scheme: protocol-relative //host is not an in-app path")
533		}
534		return
535	}
536	panic("appURL scheme must be http(s):// or a leading-slash path")
537}
538
539// parseScreenshots splits a comma-separated CID list, enforcing ≤MaxScreenshots and per-CID
540// length. Blank entries are dropped. Empty input → nil.
541func parseScreenshots(csv string) []string {
542	if csv == "" {
543		return nil
544	}
545	parts := strings.Split(csv, ",")
546	if len(parts) > MaxScreenshots {
547		panic("too many screenshots (max " + itoa(MaxScreenshots) + ")")
548	}
549	out := make([]string, 0, len(parts))
550	for _, p := range parts {
551		p = strings.TrimSpace(p)
552		if p == "" {
553			continue
554		}
555		if len(p) > MaxCIDLen {
556			panic("screenshot CID too long")
557		}
558		out = append(out, p)
559	}
560	return out
561}
562
563// ── index + counter maintenance ───────────────────────────────────────────────
564
565func statusKey(status string, id uint64) string { return status + "\x00" + zeroPad(id) }
566
567func pubKey(pub address, id uint64) string { return pub.String() + "\x00" + zeroPad(id) }
568
569// idInUse reports whether `id` is already present in the status index under ANY status (every
570// listing — registered or seeded — is in exactly one statusIndex entry). Used to reject a
571// duplicate SeedListing id before it silently collides on the composite key.
572func idInUse(id uint64) bool {
573	for _, s := range []string{StatusPending, StatusLive, StatusRejected, StatusDelisted} {
574		if _, ok := statusIndex.Get(statusKey(s, id)); ok {
575			return true
576		}
577	}
578	return false
579}
580
581// zeroPad renders id as a fixed-width 20-digit string so lexical avl order == numeric order.
582func zeroPad(id uint64) string {
583	s := itoa64(id)
584	for len(s) < 20 {
585		s = "0" + s
586	}
587	return s
588}
589
590func adjustCounter(status string, d int) {
591	switch status {
592	case StatusLive:
593		liveCount += d
594	case StatusPending:
595		pendingCount += d
596	case StatusRejected:
597		rejectedCount += d
598	case StatusDelisted:
599		delistedCount += d
600	}
601}
602
603// indexInsert adds a brand-new listing to the status + publisher indexes and bumps its status
604// counter. (publisherIndex never changes afterward — Publisher + Id are immutable.)
605func indexInsert(l *Listing) {
606	statusIndex.Set(statusKey(l.Status, l.Id), l.PkgPath)
607	publisherIndex.Set(pubKey(l.Publisher, l.Id), l.PkgPath)
608	adjustCounter(l.Status, 1)
609}
610
611// changeStatus moves a listing between statuses, keeping statusIndex + counters exact, and
612// persists the listing. Every status transition MUST go through here.
613func changeStatus(l *Listing, newStatus string) {
614	statusIndex.Remove(statusKey(l.Status, l.Id))
615	adjustCounter(l.Status, -1)
616	l.Status = newStatus
617	statusIndex.Set(statusKey(newStatus, l.Id), l.PkgPath)
618	adjustCounter(newStatus, 1)
619	listings.Set(l.PkgPath, l)
620}
621
622// validatePkgPath normalizes + sanity-checks a realm/package path.
623func validatePkgPath(p string) string {
624	if len(p) == 0 || len(p) > MaxPkgPathLen {
625		panic("pkgPath must be 1.." + itoa(MaxPkgPathLen) + " chars")
626	}
627	// Reject control and space bytes: composite avl keys separate on "\x00" and
628	// ClearFlags range-iterates the pkgPath prefix ([p+"\x00", p+"\x01")), so an
629	// embedded control byte could bleed one listing's key range into another's.
630	// Applies to RegisterApp, EditListing (via mustGet key use) AND SeedListing —
631	// a migration must not import such a path either.
632	for i := 0; i < len(p); i++ {
633		if p[i] <= 0x20 || p[i] == 0x7f {
634			panic("pkgPath contains control or space characters")
635		}
636	}
637	if !hasPrefix(p, "gno.land/r/") && !hasPrefix(p, "gno.land/p/") {
638		panic("pkgPath must be a gno.land/r/... or gno.land/p/... path")
639	}
640	return p
641}
642
643func hasPrefix(s, pre string) bool {
644	return len(s) >= len(pre) && s[:len(pre)] == pre
645}
646
647func itoa(n int) string      { return ufmt.Sprintf("%d", n) }
648func itoa64(n uint64) string { return ufmt.Sprintf("%d", n) }