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