// Package memba_appstore_v2 is a curated App Store for gno.land dApps: publishers // pay a flat listing fee to register an app; a curator flips it live. // // 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. // 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). // // TREASURY: stored LOCALLY (admin-settable, 2-step handoff, defaults to the samcrew // multisig) rather than read from memba_market_config — this keeps the realm // self-contained so the whole money path is unit-tested locally. Keep it in sync with // memba_market_config.GetTreasury(); a future version may read the fee spine directly // (which makes the realm non-locally-testable — a deliberate later trade-off). package memba_appstore_v2 import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // AdminAddress is the samcrew-core 2-of-2 multisig (same as the fee spine's admin/ // treasury). It is the initial owner AND the initial treasury. const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0" const ( // DefaultRegistrationFee is the launch listing fee: 1 GNOT (1_000_000 ugnot). // The DAO/admin can change it via SetRegistrationFee (bounded by MaxRegistrationFee). 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 MaxURLLen = 400 MaxPkgPathLen = 200 MaxCIDLen = 100 // FlagHideThreshold auto-hides a listing from the live list once this many // distinct addresses have flagged it (a curator can DelistApp / restore). FlagHideThreshold = 5 ) // StatusPending / StatusLive / StatusDelisted are the listing lifecycle states. const ( StatusPending = "pending" StatusLive = "live" 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 AppURL string Publisher address Status string FlagCount int CreatedAt int64 } var ( owner address pendingOwner address treasury address registrationFee int64 paused bool curators = avl.NewTree() // address string -> bool listings = avl.NewTree() // pkgPath -> *Listing flaggedBy = avl.NewTree() // pkgPath + "\x00" + addr -> bool (one flag per addr) nextId uint64 = 1 ) func init() { owner = address(AdminAddress) treasury = address(AdminAddress) registrationFee = DefaultRegistrationFee curators.Set(AdminAddress, true) // the admin is a curator by default } 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` and is not shown live until a curator approves it. func RegisterApp( cur realm, pkgPath, name, tagline, descr, category, iconCID, appURL string, ) uint64 { assertNotPaused() // O-13 guard: a payable entrypoint MUST be a direct user call, so an ephemeral // `maketx run` realm can never attach coins that become unrecoverable. if !unsafe.PreviousRealm().IsUserCall() { panic("RegisterApp must be a direct user call") } pkgPath = validatePkgPath(pkgPath) if _, dup := listings.Get(pkgPath); dup { panic("app already registered for this package path") } 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(appURL) > MaxURLLen { panic("appURL too long") } if len(iconCID) > MaxCIDLen { panic("iconCID too long") } // 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; we never // keep custody of a fee we can't route. 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, AppURL: appURL, Publisher: caller(), Status: StatusPending, CreatedAt: runtime.ChainHeight(), } listings.Set(pkgPath, 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 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") } l.Status = StatusLive listings.Set(pkgPath, l) chain.Emit("AppApproved", "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") } l.Status = StatusDelisted listings.Set(pkgPath, l) chain.Emit("AppDelisted", "pkgPath", pkgPath) } // RestoreApp brings a delisted app back to `pending` (re-curation required before it // shows live again). Curator-only — this is the reverse of DelistApp, so a mistaken // or contested delist is recoverable. 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") } l.Status = StatusPending listings.Set(pkgPath, l) chain.Emit("AppRestored", "pkgPath", pkgPath) } // FlagApp lets any user flag a LIVE listing once. At FlagHideThreshold distinct flags // the listing stops appearing live; a curator then DelistApp's it or leaves it for // review (a delisted app can be brought back with RestoreApp). func FlagApp(cur realm, pkgPath string) { l := mustGet(pkgPath) if l.Status != StatusLive { panic("can only flag a live 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)) } // isVisible reports whether a listing shows in the live list. func isVisible(l *Listing) bool { return l.Status == StatusLive && l.FlagCount < FlagHideThreshold } // ── 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() } // GetStatsJSON returns a small stats object for a status header. func GetStatsJSON() string { live := 0 listings.Iterate("", "", func(_ string, v any) bool { if isVisible(v.(*Listing)) { live++ } return false }) return ufmt.Sprintf(`{"total":%d,"live":%d,"registrationFee":%d,"paused":%t}`, listings.Size(), live, 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) } // 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") } // Must be a gno.land realm ("/r/") or package ("/p/") path. 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) }