Transaction
8D96D6496AE8C8…F7400FA54BA7
Block 265,716 · index 0 · indexed
Summary
- Hash
- 8D96D6496AE8C8835FC610F103046A087B4D56A1C4F07EBB55E1F7400FA54BA7
- Block
- 265,716
- Size
- 35762 bytes
- Gas used
- 50,818,425 / 90,000,000
- Fee
- 180000 ugnot
- Status
- success
Messages
- Attached funds
- 14000000 ugnot
Arguments · 15
- #1memba_appstore_v3
- #2admin.gno
- #3package memba_appstore_v3 import "chain" // Admin surface — owner-gated. The owner is the samcrew multisig at launch and can // be handed to the memba_dao executor via the 2-step TransferOwnership/AcceptOwnership // (never a 1-step transfer to a mistyped/dead address). // SetRegistrationFee sets the flat listing fee in ugnot (0..MaxRegistrationFee). // Zero is allowed (a fee waiver). Owner only. func SetRegistrationFee(cur realm, fee int64) { assertOwner() if fee < 0 || fee > MaxRegistrationFee { panic("fee out of range [0, MaxRegistrationFee]") } registrationFee = fee chain.Emit("RegistrationFeeSet", "fee", itoa64(uint64(fee))) } // SetTreasury repoints the fee recipient. Must be non-empty (an empty treasury would // fail-close RegisterApp). Owner only. Keep this in sync with // memba_market_config.GetTreasury(). func SetTreasury(cur realm, addr address) { assertOwner() if addr == "" { panic("treasury must be non-empty") } treasury = addr chain.Emit("TreasurySet", "treasury", addr.String()) } // AddCurator grants the curate role (approve pending listings). Owner only. func AddCurator(cur realm, addr address) { assertOwner() if addr == "" { panic("curator must be non-empty") } curators.Set(addr.String(), true) chain.Emit("CuratorAdded", "curator", addr.String()) } // RemoveCurator revokes the curate role. Owner only. func RemoveCurator(cur realm, addr address) { assertOwner() curators.Remove(addr.String()) chain.Emit("CuratorRemoved", "curator", addr.String()) } // Pause is the kill switch: while paused, RegisterApp aborts (reads stay available). // Owner only. func Pause(cur realm, state bool) { assertOwner() paused = state chain.Emit("PauseSet", "paused", boolStr(state)) } // TransferOwnership stages a new owner; it takes effect only after AcceptOwnership // is called BY that address (2-step, so a typo can't brick admin). A later call // replaces the staged address; CancelOwnershipTransfer withdraws it. func TransferOwnership(cur realm, newOwner address) { assertOwner() if newOwner == "" { panic("newOwner must be non-empty") } if !newOwner.IsValid() { panic("invalid address: " + newOwner.String()) } if newOwner == owner { panic("newOwner is the current owner") } pendingOwner = newOwner chain.Emit("OwnershipTransferStarted", "pending", newOwner.String()) } // CancelOwnershipTransfer withdraws a staged handoff. Owner only. func CancelOwnershipTransfer(cur realm) { assertOwner() if pendingOwner == "" { panic("no pending ownership transfer") } cancelled := pendingOwner pendingOwner = "" chain.Emit("OwnershipTransferCancelled", "pending", cancelled.String()) } // GetPendingOwner returns the staged successor, or the empty address. func GetPendingOwner() address { return pendingOwner } // AcceptOwnership completes the handoff. Only the staged pendingOwner may call it. // The former owner's curator grant is revoked atomically; the successor can // curate immediately and may explicitly re-appoint the former owner later. func AcceptOwnership(cur realm) { if !cur.IsCurrent() { panic("spoofed realm") } if pendingOwner == "" { panic("no pending ownership transfer") } if cur.Previous().Address() != pendingOwner { panic("unauthorized: only the pending owner may accept") } previous := owner curators.Set(pendingOwner.String(), true) curators.Remove(previous.String()) owner = pendingOwner pendingOwner = "" chain.Emit("OwnershipTransferAccepted", "owner", owner.String()) } func boolStr(b bool) string { if b { return "true" } return "false" }
- #4appstore.gno
- #5// Package memba_appstore_v3 is a curated App Store for gno.land dApps: publishers // pay a flat listing fee to register an app; a curator flips it live (or rejects it). // // MONEY PATH (the only one): RegisterApp collects a flat `registrationFee` in ugnot // and forwards 100% to the treasury in the SAME call — nothing is ever custodied. // The safety contract mirrors memba_token_otc_v1 + the O-13 lesson: // 1. IsUserCall() guard BEFORE reading OriginSend — an ephemeral `maketx run` // realm can never attach unrecoverable coins (the guard agent_registry missed). // 2. exact-coin via unsafe.OriginSend() (the coins on THIS call, not the wallet // balance) — closes the overpay-trap and the wallet-balance bypass. // 3. treasury-misconfig is fail-closed: an unset treasury panics (→ tx reverts → // coins refunded), never silent custody. // 4. CEI: state is written before the banker moves funds. All attacker-controlled // input (screenshots, appURL scheme) is validated BEFORE OriginSend is read. // 5. NewBanker(RealmSend, cur) sends the fee from the realm's own address to the // treasury — no custody, no escrow (which is why no escrow is needed to be safe). // // v3 over v2: a `rejected` state + RejectApp/EditListing lifecycle, ≤6 screenshots, // an on-chain appURL scheme allowlist, FlagApp extended to pending listings (the // public Unverified tab's safety valve), composite-key status/publisher indexes with // O(1) per-status counters (so status/publisher reads are bounded, not full scans), // and a sealed SeedListing migration primitive (FinalizeSeed closes the backdoor). // // TREASURY: stored LOCALLY (admin-settable, 2-step handoff, defaults to the samcrew // multisig) rather than read from memba_market_config — keeps the money path locally // unit-testable. Keep it in sync with memba_market_config.GetTreasury(). package memba_appstore_v3 import ( "strings" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/samcrew/avl" "gno.land/p/nt/ufmt/v0" ) // No authority is compiled in. The publishing transaction's signer (on // gnoland-1 the samcrew namespace multisig, the stamped creator at enable time) // is seeded as owner, treasury and first curator at package load, and every // later change goes through the owner-gated admin surface: SetTreasury repoints // the fee recipient, and TransferOwnership/AcceptOwnership hand admin to the // memba_dao executor in two steps. const ( // DefaultRegistrationFee is the launch listing fee: 1 GNOT (1_000_000 ugnot). DefaultRegistrationFee = int64(1_000_000) // MaxRegistrationFee caps a fat-finger / compromised-proposal fee at 100 GNOT. MaxRegistrationFee = int64(100_000_000) MaxNameLen = 80 MaxTaglineLen = 140 MaxDescrLen = 2000 MaxCategoryLen = 40 MaxURLLen = 400 MaxPkgPathLen = 200 MaxCIDLen = 100 MaxReasonLen = 500 // MaxScreenshots bounds the per-listing screenshot gallery. MaxScreenshots = 6 // MaxResubmits bounds how many times a publisher can edit/resubmit a listing, so a // reject→edit→pending loop can't grief the curator queue indefinitely. MaxResubmits = 5 // FlagHideThreshold auto-hides a listing (live OR pending) from the public lists once // this many distinct addresses have flagged it. FlagHideThreshold = 5 ) // Listing lifecycle states. const ( StatusPending = "pending" StatusLive = "live" StatusRejected = "rejected" StatusDelisted = "delisted" ) // Listing is one app. PkgPath (the realm/package path) is the unique key. type Listing struct { Id uint64 PkgPath string Name string Tagline string Descr string Category string IconCID string ScreenshotCIDs []string AppURL string Publisher address Status string RejectReason string PaidResubmitCredit bool ResubmitCount int FlagCount int CreatedAt int64 } var ( owner address pendingOwner address treasury address registrationFee int64 paused bool seedingSealed bool curators = avl.NewTree() // address string -> bool listings = avl.NewTree() // pkgPath -> *Listing flaggedBy = avl.NewTree() // pkgPath + "\x00" + addr -> bool (one flag per addr) // statusIndex/publisherIndex: composite-key indexes for bounded status/publisher reads. // key = status|pub + "\x00" + zeroPad(id) -> pkgPath, so a prefix range-iterate yields a // true O(offset+limit) window ordered by submission id, never a full-catalog scan. statusIndex = avl.NewTree() publisherIndex = avl.NewTree() // O(1) per-status counters, maintained on every status transition. liveCount int pendingCount int rejectedCount int delistedCount int nextId uint64 = 1 ) func init() { registrationFee = DefaultRegistrationFee seedAuthority(unsafe.OriginCaller()) } // seedAuthority makes the publisher the owner, the fee recipient and the first // curator. It runs once at package load; there is no other way to obtain owner. func seedAuthority(publisher address) { owner = publisher pendingOwner = "" treasury = publisher curators.Set(publisher.String(), true) } func caller() address { return unsafe.PreviousRealm().Address() } func assertOwner() { if caller() != owner { panic("unauthorized: owner only") } } func assertNotPaused() { if paused { panic("appstore is paused") } } // ── Money path ──────────────────────────────────────────────────────────────── // RegisterApp lists a new app. The caller pays EXACTLY registrationFee ugnot with the call; // the whole fee is forwarded to the treasury (no custody). The listing starts `pending`. func RegisterApp( cur realm, pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string, ) uint64 { assertNotPaused() // O-13 guard: a payable entrypoint MUST be a direct user call. if !unsafe.PreviousRealm().IsUserCall() { panic("RegisterApp must be a direct user call") } // Validate ALL attacker-controlled input BEFORE reading the attached coins. pkgPath = validatePkgPath(pkgPath) if _, dup := listings.Get(pkgPath); dup { panic("app already registered for this package path") } validateListingFields(name, tagline, descr, category, iconCID, appURL) shots := parseScreenshots(screenshotsCSV) // Exact-coin: read the coins attached to THIS call (not the wallet balance). sent := unsafe.OriginSend() if sent.AmountOf("ugnot") != registrationFee { panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", registrationFee, sent.AmountOf("ugnot"))) } // Fail-closed on a misconfigured treasury — reverting refunds the coins. if treasury == "" { panic("treasury unset — registration disabled") } // CEI: write state before moving funds. id := nextId nextId++ l := &Listing{ Id: id, PkgPath: pkgPath, Name: name, Tagline: tagline, Descr: descr, Category: category, IconCID: iconCID, ScreenshotCIDs: shots, AppURL: appURL, Publisher: caller(), Status: StatusPending, CreatedAt: runtime.ChainHeight(), } listings.Set(pkgPath, l) indexInsert(l) // Forward the whole fee to the treasury from the realm's own account. if registrationFee > 0 { bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins( unsafe.CurrentRealm().Address(), treasury, chain.Coins{chain.NewCoin("ugnot", registrationFee)}, ) } chain.Emit("AppRegistered", "pkgPath", pkgPath, "id", itoa64(id), "publisher", caller().String()) return id } // ── Curation & moderation ─────────────────────────────────────────────────── // ApproveApp flips a pending (or previously-live) listing live. Curator-only. func ApproveApp(cur realm, pkgPath string) { if !isCurator(caller()) { panic("unauthorized: curator only") } l := mustGet(pkgPath) if l.Status == StatusDelisted { panic("cannot approve a delisted app") } if l.Status != StatusLive { changeStatus(l, StatusLive) } chain.Emit("AppApproved", "pkgPath", pkgPath) } // RejectApp declines a pending submission, recording a reason and granting a one-time free // resubmit credit. Curator-only; only a pending app can be rejected. func RejectApp(cur realm, pkgPath, reason string) { if !isCurator(caller()) { panic("unauthorized: curator only") } if len(reason) > MaxReasonLen { panic("reason too long") } l := mustGet(pkgPath) if l.Status != StatusPending { panic("only a pending app can be rejected") } changeStatus(l, StatusRejected) l.RejectReason = reason l.PaidResubmitCredit = true listings.Set(pkgPath, l) chain.Emit("AppRejected", "pkgPath", pkgPath) } // EditListing lets the publisher update a listing that is NOT live/delisted — i.e. a pending // or rejected one — and resets it to `pending` for (re-)review. Editing a live listing is // structurally forbidden so a Verified badge can never be bait-and-switched. Bounded by // MaxResubmits so a reject→edit loop can't grief the queue. func EditListing( cur realm, pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string, ) { l := mustGet(pkgPath) if caller() != l.Publisher { panic("unauthorized: publisher only") } if l.Status != StatusPending && l.Status != StatusRejected { panic("cannot edit a live or delisted listing") } if l.ResubmitCount >= MaxResubmits { panic("resubmit limit reached") } validateListingFields(name, tagline, descr, category, iconCID, appURL) shots := parseScreenshots(screenshotsCSV) l.Name = name l.Tagline = tagline l.Descr = descr l.Category = category l.IconCID = iconCID l.ScreenshotCIDs = shots l.AppURL = appURL l.RejectReason = "" l.ResubmitCount++ if l.Status != StatusPending { changeStatus(l, StatusPending) // rejected → pending (re-review); persists via Set } else { listings.Set(pkgPath, l) } chain.Emit("AppEdited", "pkgPath", pkgPath) } // DelistApp removes a listing from public view. The publisher or a curator may do it. func DelistApp(cur realm, pkgPath string) { l := mustGet(pkgPath) if caller() != l.Publisher && !isCurator(caller()) { panic("unauthorized: publisher or curator only") } if l.Status != StatusDelisted { changeStatus(l, StatusDelisted) } chain.Emit("AppDelisted", "pkgPath", pkgPath) } // RestoreApp brings a delisted app back to `pending` (re-curation required). Curator-only. func RestoreApp(cur realm, pkgPath string) { if !isCurator(caller()) { panic("unauthorized: curator only") } l := mustGet(pkgPath) if l.Status != StatusDelisted { panic("only a delisted app can be restored") } changeStatus(l, StatusPending) chain.Emit("AppRestored", "pkgPath", pkgPath) } // FlagApp lets any user flag a publicly-listed (live OR pending) listing once. At // FlagHideThreshold distinct flags the listing drops from the public lists (isVisible), giving // the public Unverified/pending tab a community safety valve; a curator can then Delist/Reject. func FlagApp(cur realm, pkgPath string) { l := mustGet(pkgPath) if l.Status != StatusLive && l.Status != StatusPending { panic("can only flag a live or pending app") } key := pkgPath + "\x00" + caller().String() if _, done := flaggedBy.Get(key); done { panic("already flagged") } flaggedBy.Set(key, true) l.FlagCount++ listings.Set(pkgPath, l) chain.Emit("AppFlagged", "pkgPath", pkgPath, "count", itoa(l.FlagCount)) } // MaxClearBatch bounds how many per-address flag marks one ClearFlags call removes, // so a mega-brigade (thousands of sybil flags) cannot gas-lock the reset — the // curator just calls ClearFlags repeatedly until the count reaches zero. const MaxClearBatch = 200 // ClearFlags resets a listing's community-flag state after curator review. Curator-only. // Without it a flag-hidden listing stays hidden FOREVER: FlagCount never decrements and // survives every status transition, so FlagHideThreshold (5) sybil addresses could // permanently disappear any live app. Clearing also deletes the per-address dedupe marks — // the community can re-flag if the concern is real, and every clear is an emitted event, // so a curator whitewashing a bad listing is publicly visible on-chain. func ClearFlags(cur realm, pkgPath string) { if !isCurator(caller()) { panic("unauthorized: curator only") } l := mustGet(pkgPath) if l.FlagCount == 0 { panic("no flags to clear") } // Collect up to MaxClearBatch marks over the listing's prefix range (the same // [prefix+"\x00", prefix+"\x01") window idiom as reads.gno), then remove them. // Keys are collected BEFORE removal — never mutate a tree mid-Iterate. var keys []string flaggedBy.Iterate(pkgPath+"\x00", pkgPath+"\x01", func(k string, _ any) bool { keys = append(keys, k) return len(keys) >= MaxClearBatch }) for _, k := range keys { flaggedBy.Remove(k) } l.FlagCount -= len(keys) if l.FlagCount < 0 { l.FlagCount = 0 } listings.Set(pkgPath, l) chain.Emit("AppFlagsCleared", "pkgPath", pkgPath, "cleared", itoa(len(keys)), "remaining", itoa(l.FlagCount)) } // ── Migration (owner-only, sealable) ────────────────────────────────────────── // SeedListing imports a listing verbatim (Id, CreatedAt, FlagCount, Status, Publisher) during a // v2→v3 migration. Owner-only, NON-payable (never reads OriginSend / moves funds), dedupe-guarded. // After the migration the owner calls FinalizeSeed, permanently sealing this entrypoint — without // that latch it would be a standing backdoor to forge fee-free listings with arbitrary publisher. func SeedListing( cur realm, id uint64, pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL, publisherStr, status string, flagCount int, createdAt int64, ) { assertOwner() if seedingSealed { panic("seeding sealed — migration is finalized") } pkgPath = validatePkgPath(pkgPath) if _, dup := listings.Get(pkgPath); dup { panic("app already registered for this package path") } if !validStatus(status) { panic("invalid status") } // A duplicate id would collide on the composite index key (statusKey/pubKey), silently // overwriting one entry while double-counting — so reject an id already present in ANY status // (covers both a repeat seed and a seeded id that a prior RegisterApp already used). if idInUse(id) { panic("id already in use") } // Seeded data is historical user input — validate it against the SAME field + appURL-scheme // rules as a fresh registration, so an unsafe v2 appURL can't be imported past the allowlist. validateListingFields(name, tagline, descr, category, iconCID, appURL) shots := parseScreenshots(screenshotsCSV) l := &Listing{ Id: id, PkgPath: pkgPath, Name: name, Tagline: tagline, Descr: descr, Category: category, IconCID: iconCID, ScreenshotCIDs: shots, AppURL: appURL, Publisher: address(publisherStr), Status: status, FlagCount: flagCount, CreatedAt: createdAt, } listings.Set(pkgPath, l) indexInsert(l) if id >= nextId { nextId = id + 1 } chain.Emit("AppSeeded", "pkgPath", pkgPath, "id", itoa64(id)) } // FinalizeSeed permanently seals SeedListing (one-way latch). Owner-only. func FinalizeSeed(cur realm) { assertOwner() seedingSealed = true chain.Emit("SeedingFinalized") } // ── Read getters (pure, non-failing) ───────────────────────────────────────── // GetRegistrationFee returns the current flat listing fee in ugnot. func GetRegistrationFee() int64 { return registrationFee } // GetTreasury returns the address that receives listing fees. func GetTreasury() address { return treasury } // GetOwner returns the current owner (multisig, or a DAO executor after handoff). func GetOwner() address { return owner } // AppCount returns the total number of registered listings (any status). func AppCount() int { return listings.Size() } // IsCurator reports whether an address may approve/reject listings (the curator-dashboard gate). func IsCurator(a string) bool { _, ok := curators.Get(a) return ok } // GetCuratorsJSON returns a JSON array of curator addresses (small, bounded). func GetCuratorsJSON() string { var sb strings.Builder sb.WriteString("[") first := true curators.Iterate("", "", func(k string, _ any) bool { if !first { sb.WriteString(",") } first = false sb.WriteString(`"`) sb.WriteString(k) // a bech32 address — no JSON metachars sb.WriteString(`"`) return false }) sb.WriteString("]") return sb.String() } // GetStatsJSON returns per-status counts (served from O(1) counters) for the store header. func GetStatsJSON() string { return ufmt.Sprintf( `{"total":%d,"live":%d,"pending":%d,"rejected":%d,"delisted":%d,"registrationFee":%d,"paused":%t}`, listings.Size(), liveCount, pendingCount, rejectedCount, delistedCount, registrationFee, paused) } // ── internal helpers ───────────────────────────────────────────────────────── func isCurator(a address) bool { _, ok := curators.Get(a.String()) return ok } func mustGet(pkgPath string) *Listing { v, ok := listings.Get(pkgPath) if !ok { panic("app not found: " + pkgPath) } return v.(*Listing) } func validStatus(s string) bool { return s == StatusPending || s == StatusLive || s == StatusRejected || s == StatusDelisted } // validateListingFields checks the length + appURL-scheme invariants shared by RegisterApp and // EditListing. The appURL scheme allowlist (http/https/leading-slash/empty) is the on-chain // defense behind the frontend AppLink — it blocks javascript:/data:/other-scheme phishing URLs. func validateListingFields(name, tagline, descr, category, iconCID, appURL string) { if len(name) == 0 || len(name) > MaxNameLen { panic("name must be 1.." + itoa(MaxNameLen) + " chars") } if len(tagline) > MaxTaglineLen { panic("tagline too long") } if len(descr) > MaxDescrLen { panic("description too long") } if len(category) > MaxCategoryLen { panic("category too long") } if len(iconCID) > MaxCIDLen { panic("iconCID too long") } if len(appURL) > MaxURLLen { panic("appURL too long") } validateAppURL(appURL) } // validateAppURL enforces the scheme allowlist: empty, http://, https://, or a leading-slash // in-app path. Anything else (javascript:, data:, ftp:, mailto:, …) aborts. A leading-slash path // must NOT be protocol-relative (`//host` or `/\host`) — browsers navigate those off-site, which // would defeat the allowlist. func validateAppURL(u string) { if u == "" { return } if hasPrefix(u, "http://") || hasPrefix(u, "https://") { return } if u[0] == '/' { if len(u) > 1 && (u[1] == '/' || u[1] == '\\') { panic("appURL scheme: protocol-relative //host is not an in-app path") } return } panic("appURL scheme must be http(s):// or a leading-slash path") } // parseScreenshots splits a comma-separated CID list, enforcing ≤MaxScreenshots and per-CID // length. Blank entries are dropped. Empty input → nil. func parseScreenshots(csv string) []string { if csv == "" { return nil } parts := strings.Split(csv, ",") if len(parts) > MaxScreenshots { panic("too many screenshots (max " + itoa(MaxScreenshots) + ")") } out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } if len(p) > MaxCIDLen { panic("screenshot CID too long") } out = append(out, p) } return out } // ── index + counter maintenance ─────────────────────────────────────────────── func statusKey(status string, id uint64) string { return status + "\x00" + zeroPad(id) } func pubKey(pub address, id uint64) string { return pub.String() + "\x00" + zeroPad(id) } // idInUse reports whether `id` is already present in the status index under ANY status (every // listing — registered or seeded — is in exactly one statusIndex entry). Used to reject a // duplicate SeedListing id before it silently collides on the composite key. func idInUse(id uint64) bool { for _, s := range []string{StatusPending, StatusLive, StatusRejected, StatusDelisted} { if _, ok := statusIndex.Get(statusKey(s, id)); ok { return true } } return false } // zeroPad renders id as a fixed-width 20-digit string so lexical avl order == numeric order. func zeroPad(id uint64) string { s := itoa64(id) for len(s) < 20 { s = "0" + s } return s } func adjustCounter(status string, d int) { switch status { case StatusLive: liveCount += d case StatusPending: pendingCount += d case StatusRejected: rejectedCount += d case StatusDelisted: delistedCount += d } } // indexInsert adds a brand-new listing to the status + publisher indexes and bumps its status // counter. (publisherIndex never changes afterward — Publisher + Id are immutable.) func indexInsert(l *Listing) { statusIndex.Set(statusKey(l.Status, l.Id), l.PkgPath) publisherIndex.Set(pubKey(l.Publisher, l.Id), l.PkgPath) adjustCounter(l.Status, 1) } // changeStatus moves a listing between statuses, keeping statusIndex + counters exact, and // persists the listing. Every status transition MUST go through here. func changeStatus(l *Listing, newStatus string) { statusIndex.Remove(statusKey(l.Status, l.Id)) adjustCounter(l.Status, -1) l.Status = newStatus statusIndex.Set(statusKey(newStatus, l.Id), l.PkgPath) adjustCounter(newStatus, 1) listings.Set(l.PkgPath, l) } // validatePkgPath normalizes + sanity-checks a realm/package path. func validatePkgPath(p string) string { if len(p) == 0 || len(p) > MaxPkgPathLen { panic("pkgPath must be 1.." + itoa(MaxPkgPathLen) + " chars") } // Reject control and space bytes: composite avl keys separate on "\x00" and // ClearFlags range-iterates the pkgPath prefix ([p+"\x00", p+"\x01")), so an // embedded control byte could bleed one listing's key range into another's. // Applies to RegisterApp, EditListing (via mustGet key use) AND SeedListing — // a migration must not import such a path either. for i := 0; i < len(p); i++ { if p[i] <= 0x20 || p[i] == 0x7f { panic("pkgPath contains control or space characters") } } if !hasPrefix(p, "gno.land/r/") && !hasPrefix(p, "gno.land/p/") { panic("pkgPath must be a gno.land/r/... or gno.land/p/... path") } return p } func hasPrefix(s, pre string) bool { return len(s) >= len(pre) && s[:len(pre)] == pre } func itoa(n int) string { return ufmt.Sprintf("%d", n) } func itoa64(n uint64) string { return ufmt.Sprintf("%d", n) }
- #6gnomod.toml
- #7module = "gno.land/r/samcrew/memba_appstore_v3" gno = "0.9"
- #8governance_reads.gno
- #9package memba_appstore_v3 import ( "crypto/sha256" "encoding/hex" "strings" ) func IsPaused() bool { return paused } func IsSeedingSealed() bool { return seedingSealed } // Scalars only: no persistent Listing pointer or screenshot slice escapes. type GovernanceListing struct { Exists bool ContentHash, Status, Reason string Credit bool Flags int ClearKeys string } func GetGovernanceListing(pkgPath string, clearBatch bool) GovernanceListing { v, ok := listings.Get(pkgPath) if !ok { return GovernanceListing{} } l := v.(*Listing) snapshot := *l snapshot.Status = "" snapshot.RejectReason = "" snapshot.PaidResubmitCredit = false snapshot.FlagCount = 0 digest := sha256.Sum256([]byte(listingJSON(&snapshot, true))) s := GovernanceListing{Exists: true, ContentHash: hex.EncodeToString(digest[:]), Status: l.Status, Reason: l.RejectReason, Credit: l.PaidResubmitCredit, Flags: l.FlagCount} if clearBatch { keys := []string{} flaggedBy.Iterate(pkgPath+"\x00", pkgPath+"\x01", func(k string, _ any) bool { keys = append(keys, strings.TrimPrefix(k, pkgPath+"\x00")) return len(keys) >= MaxClearBatch }) s.ClearKeys = strings.Join(keys, ",") } return s } func HasGovernanceFlag(pkgPath, who string) bool { _, ok := flaggedBy.Get(pkgPath + "\x00" + who) return ok }
- #10reads.gno
- #11package memba_appstore_v3 import ( "strings" "gno.land/p/samcrew/avl" "gno.land/p/nt/ufmt/v0" ) const ( DefaultPageSize = 20 // default JSON window MaxPageLimit = 100 // hard cap on any JSON read window (read-DoS guard) ) func clampLimit(limit int) int { if limit <= 0 { return DefaultPageSize } if limit > MaxPageLimit { return MaxPageLimit } return limit } // jsonEscape escapes a string for embedding in a JSON string literal — every listing text field // is attacker-supplied, so this is the only thing between a malicious name/tagline/reason/CID and // a broken/injected JSON payload on the read path. func jsonEscape(s string) string { var sb strings.Builder for _, r := range s { switch r { case '"': sb.WriteString("\\\"") case '\\': sb.WriteString("\\\\") case '\n': sb.WriteString("\\n") case '\r': sb.WriteString("\\r") case '\t': sb.WriteString("\\t") default: if r < 0x20 { sb.WriteString(ufmt.Sprintf("\\u%04x", r)) } else { sb.WriteString(string(r)) } } } return sb.String() } // screenshotsJSON renders a CID slice as a JSON string array body (each element escaped). func screenshotsJSON(cids []string) string { var sb strings.Builder for i, c := range cids { if i > 0 { sb.WriteString(",") } sb.WriteString(`"`) sb.WriteString(jsonEscape(c)) sb.WriteString(`"`) } return sb.String() } // listingJSON renders one listing. `full` adds the (large) description + the screenshot gallery — // omitted from list windows so a bounded page stays small. rejectReason is short and always // included (the My-Submissions view needs it in list windows). func listingJSON(l *Listing, full bool) string { extra := "" if full { extra = ufmt.Sprintf(`,"descr":"%s","screenshotCIDs":[%s]`, jsonEscape(l.Descr), screenshotsJSON(l.ScreenshotCIDs)) } return ufmt.Sprintf( `{"id":%d,"pkgPath":"%s","name":"%s","tagline":"%s","category":"%s","iconCID":"%s","appURL":"%s","publisher":"%s","status":"%s","rejectReason":"%s","paidResubmitCredit":%t,"resubmitCount":%d,"flagCount":%d,"createdAt":%d%s}`, l.Id, jsonEscape(l.PkgPath), jsonEscape(l.Name), jsonEscape(l.Tagline), jsonEscape(l.Category), jsonEscape(l.IconCID), jsonEscape(l.AppURL), l.Publisher.String(), l.Status, jsonEscape(l.RejectReason), l.PaidResubmitCredit, l.ResubmitCount, l.FlagCount, l.CreatedAt, extra, ) } // windowByPrefix iterates a composite-key index (statusIndex or publisherIndex) over the prefix // range [prefix+"\x00", prefix+"\x01") — a true O(offset+limit) window ordered by zero-padded id. // When flagFilter is set, flag-hidden listings are excluded (the public live/pending lists). func windowByPrefix(index *avl.Tree, prefix string, offset, limit int, flagFilter bool) string { limit = clampLimit(limit) if offset < 0 { offset = 0 } var sb strings.Builder sb.WriteString("[") skipped, taken := 0, 0 first := true index.Iterate(prefix+"\x00", prefix+"\x01", func(_ string, v any) bool { lv, ok := listings.Get(v.(string)) if !ok { return false } l := lv.(*Listing) if flagFilter && l.FlagCount >= FlagHideThreshold { return false } if skipped < offset { skipped++ return false } if taken >= limit { return true // window full — stop } if !first { sb.WriteString(",") } first = false sb.WriteString(listingJSON(l, false)) taken++ return false }) sb.WriteString("]") return sb.String() } // ListLiveJSON returns a bounded JSON array of the visible (live, un-flag-hidden) listings — // the Verified tab. Bounded by scanning only the "live" index slice. func ListLiveJSON(offset, limit int) string { return windowByPrefix(statusIndex, StatusLive, offset, limit, true) } // ListByStatusJSON returns a bounded window of listings in `status`. Public tabs (live, pending) // exclude flag-hidden listings; rejected/delisted are returned raw (used by curator/owner views). // An unknown status returns an empty array — the client passes a fixed enum, never free text. func ListByStatusJSON(status string, offset, limit int) string { if !validStatus(status) { return "[]" } flagFilter := status == StatusLive || status == StatusPending return windowByPrefix(statusIndex, status, offset, limit, flagFilter) } // ListByPublisherJSON returns a bounded window of a publisher's listings across ALL statuses // (the My-Submissions view) — no flag filter, since a publisher sees their own flagged/rejected. func ListByPublisherJSON(publisher string, offset, limit int) string { return windowByPrefix(publisherIndex, publisher, offset, limit, false) } // GetListingJSON returns a single listing (any status) with its full detail (descr + screenshots), // or the JSON literal `null` if the package path is not registered. func GetListingJSON(pkgPath string) string { v, ok := listings.Get(pkgPath) if !ok { return "null" } return listingJSON(v.(*Listing), true) }
- #12render.gno
- #13package memba_appstore_v3 import ( "chain/runtime" "gno.land/p/nt/ufmt/v0" ) // RenderMaxRows bounds the Render() output so a large catalog can never make the // realm render-DoS (mirrors the feed's live-only bounded render). const RenderMaxRows = 100 // Render shows the live catalog (bounded) — a read-only trust surface for gnoweb. // The frontend reads structured data via the getters, not this markdown. func Render(path string) string { sb := "" sb += "# Memba App Store\n\n" sb += ufmt.Sprintf("Curated gno.land dApps. **Listing fee:** %d ugnot → treasury.\n\n", registrationFee) if paused { sb += "> ⏸️ Registration is paused.\n\n" } rows := 0 live := 0 sb += "| App | Package | Category |\n|---|---|---|\n" listings.Iterate("", "", func(_ string, v any) bool { l := v.(*Listing) // Curated gnoweb view: live (Verified) + un-flag-hidden only — never pending/rejected. if l.Status != StatusLive || l.FlagCount >= FlagHideThreshold { return false } live++ if rows >= RenderMaxRows { return false // stop appending, keep counting via the header below } rows++ sb += ufmt.Sprintf("| %s | `%s` | %s |\n", esc(l.Name), esc(l.PkgPath), esc(l.Category)) return false }) if live == 0 { sb += "\n*No live apps yet.*\n" } else { sb += ufmt.Sprintf("\n*%d live app(s)%s. Block %d.*\n", live, moreNote(live), runtime.ChainHeight()) } return sb } func moreNote(live int) string { if live > RenderMaxRows { return ufmt.Sprintf(" (showing first %d)", RenderMaxRows) } return "" } // esc neutralizes the markdown/table metachars that could break the table or inject // layout from an attacker-supplied listing field. func esc(s string) string { out := "" for _, r := range s { switch r { case '|': out += "\\|" case '\n', '\r': out += " " case '`': out += "'" default: out += string(r) } } return out }
- #14/gno.MemPackageType
- #15 MPUserAll
Result log
msg:0,success:true,log:,events:[]