// Package memba_appstore_v1 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_v1
import (
"chain"
"chain/banker"
"chain/runtime"
"chain/runtime/unsafe"
"gno.land/p/samcrew/avl"
"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 = "g10kw7e55e9wc8j8v6904ck29dqwr9fm9u280juh"
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) }