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