Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

memba_feed_v1.gno

37.96 Kb · 1202 lines
   1package memba_feed_v1
   2
   3// memba_feed_v1 — Global social feed realm for Memba (W7.2 P0).
   4//
   5// OPEN-WRITE: any wallet may post. This inverts every assumption of the
   6// DAO-scoped memba_dao_channels_v2 (members-only, 20 channels / 500 threads),
   7// so the realm is NEW — what is ported VERBATIM from channels_v2 is its
   8// hardening discipline:
   9//
  10//   B1 render-DoS bounds — reads NEVER iterate the monotonic nextPostID.
  11//     Live (visible) posts are tracked in dedicated AVL indexes and every
  12//     read path is paginated to a fixed window. channels_v2 used []uint64
  13//     slices (safe under its 500-per-channel cap); an open feed is unbounded,
  14//     so the live indexes here are composite-key AVL trees instead —
  15//     O(log n) insert/remove, O(page) scan, no single node that grows forever.
  16//   B2 state-shrink — author deletes soft-delete + enqueue a tombstone;
  17//     SweepTombstones hard-removes nodes (bounded, permissionless GC).
  18//   B3 pause policy — every user-facing write is blocked while paused;
  19//     owner/moderation stays operational (no funds in this realm).
  20//   Flag pipeline — one flag per address per post, threshold auto-hide.
  21//     Open write invites brigading, so the threshold is higher than
  22//     channels_v2's 3 and flagging is further gated by account age and a
  23//     per-day flag budget.
  24//
  25// Anti-spam (open write has no membership gate to lean on):
  26//   - per-address block cooldown between posts, stricter for accounts first
  27//     seen fewer than YoungAccountBlocks ago;
  28//   - body length cap; media CID count cap (CIDs stored on-chain only —
  29//     pinning/serving is the backend's PinMedia pipeline, P2).
  30//
  31// Reads: exported *JSON funcs via RPC vm/qeval — cursor-paginated, O(limit)
  32// at any depth (the app reads from the indexer; these are the fallback).
  33// Render() is the human gnoweb view: bounded pages with a hard depth cap
  34// (MaxRenderPage) — deep history belongs to the cursor API, not qrender.
  35//
  36// Moderation P0: realm owner only (ModRemovePost / UnhidePost / BanAuthor is
  37// W8.2's moderation board via a daokit moderator role — p/samcrew/modboard).
  38// Every moderation action emits for public audit.
  39//
  40// This realm holds NO funds: no banker, no OriginSend, no fee lane. A future
  41// tipping lane is a separate SAFETY_GATED flag + fee-spine lane "feed".
  42
  43import (
  44	"strconv"
  45	"strings"
  46
  47	"gno.land/p/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/avl"
  48	"gno.land/p/nt/ufmt/v0"
  49
  50	"chain"
  51	"chain/runtime"
  52	"chain/runtime/unsafe"
  53)
  54
  55// ── Constants ────────────────────────────────────────────────
  56
  57const (
  58	MaxBodyLen   = 1000 // post body (roadmap 4.1)
  59	MaxMediaCIDs = 4    // stored per post; pin/serve pipeline is P2
  60	MaxCIDLen    = 128  // defensive cap on a single CID string
  61
  62	FeedPageSize  = 20  // posts per rendered page / default JSON window
  63	MaxPageLimit  = 100 // hard cap on any JSON read window (DoS guard)
  64	MaxRenderPage = 50  // deepest gnoweb page; beyond → use the cursor API
  65
  66	// B1 reply bound (mirrors channels_v2's MaxRepliesPerThread). Caps the
  67	// LIVE reply set per post so (a) the byParent index under any one parent is
  68	// bounded, and (b) SweepTombstones can range-delete a swept parent's reply
  69	// links in bounded work. Deleting a reply frees a slot.
  70	MaxRepliesPerPost = 500
  71
  72	// Flag pipeline. channels_v2 auto-hides at 3 member flags; the feed is
  73	// open-write, so the threshold starts higher and flagging is rate-limited.
  74	// These are damping knobs, not a brigade-proof gate: 5 aged sybils can
  75	// still hide a post (the age gate is one post + a one-time wait, farmable
  76	// in parallel). They raise the cost and slow coordination; genuine brigade
  77	// resistance is the W8.2 moderation board (reversible mod actions + audit).
  78	// All are governance-tunable post-deploy via a future realm version; they
  79	// are constants here so the adversarial tests pin their behavior.
  80	FlagThreshold        = 5     // unique flags before auto-hide
  81	MinAccountAgeForFlag = 1200  // blocks since first post (~1-2h)
  82	FlagsPerDayBudget    = 10    // per address per BlocksPerDay window
  83	BlocksPerDay         = 17280 // ~5s blocks; approximation is fine
  84
  85	// Posting cooldowns (blocks). Young accounts (first seen < YoungAccountBlocks
  86	// ago) wait longer between posts — cheap sybil throttle.
  87	MinPostIntervalBlocks      = 2
  88	YoungMinPostIntervalBlocks = 12
  89	YoungAccountBlocks         = 17280 // first ~day
  90)
  91
  92// ── Types ────────────────────────────────────────────────────
  93
  94type Post struct {
  95	ID        uint64
  96	Author    address
  97	Body      string
  98	MediaCIDs []string // IPFS CIDs only; serving goes through the backend proxy
  99	ReplyTo   uint64   // 0 = top-level
 100	RepostOf  uint64   // 0 = original (entrypoint lands in P1; field is schema-stable)
 101	BlockH    int64
 102	EditedAt  int64 // block height of last edit (0 = never)
 103	FlagCount int
 104	Hidden    bool // flag auto-hide or moderation hide
 105	Deleted   bool // author tombstone (awaiting SweepTombstones hard-GC)
 106}
 107
 108type flagBudget struct {
 109	DayStartH int64
 110	Used      int
 111}
 112
 113// ── State ────────────────────────────────────────────────────
 114
 115var (
 116	posts *avl.Tree // padID(id) -> *Post (live + hidden + not-yet-swept tombstones)
 117
 118	// Live indexes (B1): ONLY visible (not hidden, not deleted) posts.
 119	// Every read path iterates one of these — never `posts`, never nextPostID.
 120	liveFeed   *avl.Tree // padID(id) -> true
 121	byAuthor   *avl.Tree // author + ":" + padID(id) -> true
 122	byParent   *avl.Tree // padID(parent) + ":" + padID(child) -> true
 123	liveCount  uint64    // number of keys in liveFeed
 124	replyCount *avl.Tree // padID(parent) -> uint64 (live replies)
 125
 126	nextPostID uint64 // monotonic; NEVER iterated by reads
 127
 128	// Anti-spam / flag state.
 129	lastPostH  *avl.Tree // addr -> int64 (last CreatePost height)
 130	firstSeenH *avl.Tree // addr -> int64 (first write interaction height)
 131	flags      *avl.Tree // padID(id) -> *avl.Tree (flagger addr -> true)
 132	flagSpend  *avl.Tree // addr -> *flagBudget
 133
 134	// Reactions (one-per-emoji toggle). padID(id) -> *avl.Tree keyed by
 135	// reactionSubKey(emoji, addr) -> true. Enforces per-(post,emoji,addr)
 136	// uniqueness on-chain; per-emoji COUNTS are aggregated off-chain by the
 137	// indexer from ReactionAdded/ReactionRemoved (no unbounded on-chain scan —
 138	// B1 render bounds hold; a hot post's reaction set is never iterated here).
 139	reactions *avl.Tree
 140
 141	// Soft-deleted post IDs awaiting hard-GC (B2). An AVL tree (not a slice):
 142	// a slice re-serializes wholesale on every enqueue, so post+delete spam
 143	// would inflate the gas of every write touching it (a slow write-path DoS).
 144	// The tree gives O(log n) enqueue and lets the sweep drain a bounded
 145	// ascending window without rewriting the backlog.
 146	tombstones *avl.Tree // padID(id) -> true
 147
 148	// Moderator set (W8.2): owner-granted addresses that can ModRemovePost /
 149	// UnhidePost WITHOUT owner-multisig-per-post — fast, reversible moderation.
 150	// Granting/revoking stays owner-only. addr -> true.
 151	moderators *avl.Tree
 152
 153	paused bool
 154	// owner is the publishing transaction's signer, captured at package load
 155	// (on gnoland-1 the samcrew namespace multisig, the stamped creator at enable
 156	// time) — same pattern as channels_v2. pendingOwner is the staged successor of
 157	// a two-step handoff, or empty.
 158	owner        = unsafe.OriginCaller()
 159	pendingOwner address
 160)
 161
 162func init() {
 163	posts = avl.NewTree()
 164	liveFeed = avl.NewTree()
 165	byAuthor = avl.NewTree()
 166	byParent = avl.NewTree()
 167	replyCount = avl.NewTree()
 168	lastPostH = avl.NewTree()
 169	firstSeenH = avl.NewTree()
 170	flags = avl.NewTree()
 171	flagSpend = avl.NewTree()
 172	reactions = avl.NewTree()
 173	moderators = avl.NewTree()
 174	tombstones = avl.NewTree()
 175	nextPostID = 1
 176}
 177
 178// ── Helpers ──────────────────────────────────────────────────
 179
 180// padID zero-pads to 12 digits so AVL string order == numeric order.
 181// 12 digits ≥ 31,000 years of one post per block — effectively unbounded.
 182func padID(id uint64) string {
 183	s := strconv.FormatUint(id, 10)
 184	for len(s) < 12 {
 185		s = "0" + s
 186	}
 187	return s
 188}
 189
 190func getPost(id uint64) (*Post, bool) {
 191	v, ok := posts.Get(padID(id))
 192	if !ok {
 193		return nil, false
 194	}
 195	return v.(*Post), true
 196}
 197
 198func mustGetPost(id uint64) *Post {
 199	p, ok := getPost(id)
 200	if !ok {
 201		panic("post not found: " + strconv.FormatUint(id, 10))
 202	}
 203	return p
 204}
 205
 206func authorKey(addr address, id uint64) string { return addr.String() + ":" + padID(id) }
 207func parentKey(parent, child uint64) string    { return padID(parent) + ":" + padID(child) }
 208
 209func getReplyCount(parent uint64) uint64 {
 210	if v, ok := replyCount.Get(padID(parent)); ok {
 211		return v.(uint64)
 212	}
 213	return 0
 214}
 215
 216// addToLiveIndexes registers a visible post in every live index (B1).
 217//
 218// A reply is only linked into its parent's byParent/replyCount indexes when the
 219// parent is still LIVE. On CreatePost the parent was just verified live, so this
 220// is always true there. The guard matters on the UnhidePost re-add path: if the
 221// parent was deleted+swept while the reply sat hidden, re-linking would recreate
 222// an orphan byParent/replyCount entry under a parent id no longer in `posts`
 223// (never sweepable again) — the exact leak SweepTombstones' byParent-prefix
 224// cleanup was added to prevent. An unhidden reply whose parent is gone simply
 225// becomes standalone live content, which is how a reply to a removed parent is
 226// already treated everywhere else.
 227func addToLiveIndexes(p *Post) {
 228	liveFeed.Set(padID(p.ID), true)
 229	byAuthor.Set(authorKey(p.Author, p.ID), true)
 230	liveCount++
 231	if p.ReplyTo != 0 && liveFeed.Has(padID(p.ReplyTo)) {
 232		byParent.Set(parentKey(p.ReplyTo, p.ID), true)
 233		replyCount.Set(padID(p.ReplyTo), getReplyCount(p.ReplyTo)+1)
 234	}
 235}
 236
 237// removeFromLiveIndexes drops a post from every live index (hide/delete).
 238func removeFromLiveIndexes(p *Post) {
 239	if _, ok := liveFeed.Get(padID(p.ID)); !ok {
 240		return // already invisible (e.g. delete after auto-hide)
 241	}
 242	liveFeed.Remove(padID(p.ID))
 243	byAuthor.Remove(authorKey(p.Author, p.ID))
 244	if liveCount > 0 {
 245		liveCount--
 246	}
 247	if p.ReplyTo != 0 {
 248		byParent.Remove(parentKey(p.ReplyTo, p.ID))
 249		if rc := getReplyCount(p.ReplyTo); rc > 0 {
 250			replyCount.Set(padID(p.ReplyTo), rc-1)
 251		}
 252	}
 253}
 254
 255func assertNotPaused() {
 256	if paused {
 257		panic("realm is paused — emergency maintenance")
 258	}
 259}
 260
 261func assertCallerIsOwner() {
 262	caller := unsafe.PreviousRealm().Address()
 263	if caller != owner {
 264		panic("unauthorized: caller " + caller.String() + " is not the owner")
 265	}
 266}
 267
 268// touchFirstSeen records the first write interaction height for an address.
 269func touchFirstSeen(addr address, h int64) {
 270	if _, ok := firstSeenH.Get(addr.String()); !ok {
 271		firstSeenH.Set(addr.String(), h)
 272	}
 273}
 274
 275func accountAge(addr address, now int64) int64 {
 276	if v, ok := firstSeenH.Get(addr.String()); ok {
 277		return now - v.(int64)
 278	}
 279	return 0
 280}
 281
 282// ── Emergency pause (B3 — one policy, enforced everywhere) ───
 283// While paused every user-facing content write is blocked. Owner moderation
 284// and pause management stay live: pause halts user activity during an
 285// incident, but the owner must stay able to act. No funds here, so there is
 286// no value-exit exemption to carve out.
 287
 288func PauseRealm(cur realm) {
 289	assertCallerIsOwner()
 290	paused = true
 291	chain.Emit("RealmPaused", "by", owner.String())
 292}
 293
 294func UnpauseRealm(cur realm) {
 295	assertCallerIsOwner()
 296	paused = false
 297	chain.Emit("RealmUnpaused", "by", owner.String())
 298}
 299
 300func IsPaused() bool { return paused }
 301
 302// TransferOwnership stages a successor owner. Nothing moves until that address
 303// calls AcceptOwnership with its own transaction, so ownership can never land on
 304// an address that cannot act. A later call replaces the staged address;
 305// CancelOwnershipTransfer withdraws it.
 306func TransferOwnership(cur realm, newOwner address) {
 307	assertCallerIsOwner()
 308	if newOwner == "" {
 309		panic("address cannot be empty")
 310	}
 311	if !newOwner.IsValid() {
 312		panic("invalid address: " + newOwner.String())
 313	}
 314	if newOwner == owner {
 315		panic("new owner is the same as current owner")
 316	}
 317	pendingOwner = newOwner
 318	chain.Emit("OwnershipTransferStarted", "pending", newOwner.String())
 319}
 320
 321// CancelOwnershipTransfer withdraws a staged handoff. Owner only.
 322func CancelOwnershipTransfer(cur realm) {
 323	assertCallerIsOwner()
 324	if pendingOwner == "" {
 325		panic("no pending ownership transfer")
 326	}
 327	cancelled := pendingOwner
 328	pendingOwner = ""
 329	chain.Emit("OwnershipTransferCancelled", "pending", cancelled.String())
 330}
 331
 332// AcceptOwnership completes the handoff. Only the staged pendingOwner may call it.
 333func AcceptOwnership(cur realm) {
 334	if !cur.IsCurrent() {
 335		panic("spoofed realm")
 336	}
 337	caller := cur.Previous().Address()
 338	if pendingOwner == "" || caller != pendingOwner {
 339		panic("unauthorized: caller " + caller.String() + " is not the pending owner")
 340	}
 341	prev := owner
 342	owner = caller
 343	pendingOwner = ""
 344	chain.Emit("OwnershipTransferred",
 345		"previousOwner", prev.String(),
 346		"newOwner", caller.String(),
 347	)
 348}
 349
 350// GetOwner returns the current realm owner address.
 351func GetOwner() address { return owner }
 352
 353// GetPendingOwner returns the staged successor, or the empty address.
 354func GetPendingOwner() address { return pendingOwner }
 355
 356// ── Writes ───────────────────────────────────────────────────
 357
 358// CreatePost publishes a post (replyTo == 0) or a reply (replyTo == parent id).
 359// Open write: any wallet, subject to the block cooldown and body caps.
 360// Returns the new post id.
 361func CreatePost(cur realm, body string, replyTo uint64) uint64 {
 362	assertNotPaused()
 363	caller := unsafe.PreviousRealm().Address()
 364	now := runtime.ChainHeight()
 365
 366	// Cooldown BEFORE touching state: young accounts wait longer.
 367	touchFirstSeen(caller, now)
 368	interval := int64(MinPostIntervalBlocks)
 369	if accountAge(caller, now) < YoungAccountBlocks {
 370		interval = YoungMinPostIntervalBlocks
 371	}
 372	if v, ok := lastPostH.Get(caller.String()); ok {
 373		if now-v.(int64) < interval {
 374			panic(ufmt.Sprintf("posting too fast: wait %d blocks between posts", interval))
 375		}
 376	}
 377
 378	if len(body) == 0 || len(body) > MaxBodyLen {
 379		panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
 380	}
 381
 382	var parent *Post
 383	if replyTo != 0 {
 384		parent = mustGetPost(replyTo)
 385		if parent.Deleted {
 386			panic("cannot reply to a deleted post")
 387		}
 388		if parent.Hidden {
 389			panic("cannot reply to a hidden post")
 390		}
 391		// B1: bound the LIVE reply set so the byParent index under one parent
 392		// stays bounded (and a swept parent's link cleanup stays bounded).
 393		if getReplyCount(replyTo) >= MaxRepliesPerPost {
 394			panic(ufmt.Sprintf("reply limit reached: %d per post", MaxRepliesPerPost))
 395		}
 396	}
 397
 398	id := nextPostID
 399	nextPostID++
 400
 401	p := &Post{
 402		ID:      id,
 403		Author:  caller,
 404		Body:    body,
 405		ReplyTo: replyTo,
 406		BlockH:  now,
 407	}
 408	posts.Set(padID(id), p)
 409	addToLiveIndexes(p)
 410	lastPostH.Set(caller.String(), now)
 411
 412	chain.Emit("PostCreated",
 413		"postId", strconv.FormatUint(id, 10),
 414		"author", caller.String(),
 415		"replyTo", strconv.FormatUint(replyTo, 10),
 416		"body", body,
 417	)
 418	return id
 419}
 420
 421// EditPost lets the author replace the body of a visible post.
 422func EditPost(cur realm, id uint64, newBody string) {
 423	assertNotPaused()
 424	caller := unsafe.PreviousRealm().Address()
 425
 426	p := mustGetPost(id)
 427	if p.Author != caller {
 428		panic("only the author can edit")
 429	}
 430	if p.Deleted {
 431		panic("cannot edit a deleted post")
 432	}
 433	if p.Hidden {
 434		panic("cannot edit a hidden post")
 435	}
 436	if len(newBody) == 0 || len(newBody) > MaxBodyLen {
 437		panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
 438	}
 439
 440	p.Body = newBody
 441	p.EditedAt = runtime.ChainHeight()
 442	posts.Set(padID(id), p)
 443
 444	chain.Emit("PostEdited",
 445		"postId", strconv.FormatUint(id, 10),
 446		"author", caller.String(),
 447		"body", newBody,
 448	)
 449}
 450
 451// DeletePost soft-deletes the author's own post: content cleared, dropped from
 452// every live index (frees render slots immediately — B1), queued for hard-GC
 453// (B2). Replies stay: they render as replies to an unavailable post.
 454func DeletePost(cur realm, id uint64) {
 455	assertNotPaused()
 456	caller := unsafe.PreviousRealm().Address()
 457
 458	p := mustGetPost(id)
 459	if p.Author != caller {
 460		panic("only the author can delete")
 461	}
 462	if p.Deleted {
 463		panic("post already deleted")
 464	}
 465
 466	removeFromLiveIndexes(p)
 467	p.Deleted = true
 468	p.Body = ""
 469	p.MediaCIDs = nil
 470	posts.Set(padID(id), p)
 471	tombstones.Set(padID(id), true)
 472
 473	chain.Emit("PostDeleted",
 474		"postId", strconv.FormatUint(id, 10),
 475		"author", caller.String(),
 476	)
 477}
 478
 479// ── Flag pipeline ────────────────────────────────────────────
 480
 481// FlagPost files a community flag. Guards, in order:
 482//   - flag rights are EARNED BY PARTICIPATION: the caller must have a
 483//     firstSeen record (anchored by their first successful CreatePost) at
 484//     least MinAccountAgeForFlag blocks old. A failed flag cannot anchor age
 485//     itself — an on-chain abort reverts every write in the tx, so recording
 486//     firstSeen here and then panicking would revert the record and deadlock
 487//     pure flaggers; requiring a prior post is the honest, implementable
 488//     account-age gate (realms cannot query global account age);
 489//   - one flag per address per post;
 490//   - per-day flag budget per address (blunts coordinated flag-brigades).
 491//
 492// At FlagThreshold unique flags the post auto-hides (reversible via UnhidePost).
 493func FlagPost(cur realm, id uint64) {
 494	assertNotPaused()
 495	caller := unsafe.PreviousRealm().Address()
 496	now := runtime.ChainHeight()
 497
 498	if accountAge(caller, now) < MinAccountAgeForFlag {
 499		panic(ufmt.Sprintf("flagging requires having posted at least %d blocks ago", MinAccountAgeForFlag))
 500	}
 501
 502	p := mustGetPost(id)
 503	if p.Deleted {
 504		panic("cannot flag a deleted post")
 505	}
 506	if p.Hidden {
 507		panic("post is already hidden")
 508	}
 509
 510	// Per-day budget window.
 511	var fb *flagBudget
 512	if v, ok := flagSpend.Get(caller.String()); ok {
 513		fb = v.(*flagBudget)
 514	} else {
 515		fb = &flagBudget{DayStartH: now}
 516	}
 517	if now-fb.DayStartH >= BlocksPerDay {
 518		fb.DayStartH = now
 519		fb.Used = 0
 520	}
 521	if fb.Used >= FlagsPerDayBudget {
 522		panic(ufmt.Sprintf("daily flag budget reached: %d per %d blocks", FlagsPerDayBudget, BlocksPerDay))
 523	}
 524
 525	// Unique flaggers per post.
 526	var flagTree *avl.Tree
 527	if v, ok := flags.Get(padID(id)); ok {
 528		flagTree = v.(*avl.Tree)
 529	} else {
 530		flagTree = avl.NewTree()
 531	}
 532	if _, already := flagTree.Get(caller.String()); already {
 533		panic("already flagged")
 534	}
 535	flagTree.Set(caller.String(), true)
 536	flags.Set(padID(id), flagTree)
 537
 538	fb.Used++
 539	flagSpend.Set(caller.String(), fb)
 540
 541	p.FlagCount = flagTree.Size()
 542	wasHidden := p.Hidden
 543	if p.FlagCount >= FlagThreshold {
 544		p.Hidden = true
 545		removeFromLiveIndexes(p)
 546		// A flag-hidden post is deliberately NOT tombstoned: its node and its
 547		// `flags` subtree (the flagger set) are retained on purpose as the audit
 548		// trail the W8.2 moderation board reviews (and UnhidePost needs, to
 549		// reverse a brigade). It leaves the live indexes (B1 render bounds still
 550		// hold), so this is bounded, intentional retention — not a leak. Author
 551		// delete / mod-remove are the paths that tombstone for GC.
 552	}
 553	posts.Set(padID(id), p)
 554
 555	chain.Emit("PostFlagged",
 556		"postId", strconv.FormatUint(id, 10),
 557		"flagger", caller.String(),
 558		"flagCount", strconv.Itoa(p.FlagCount),
 559	)
 560	if !wasHidden && p.Hidden {
 561		chain.Emit("PostAutoHidden", "postId", strconv.FormatUint(id, 10))
 562	}
 563}
 564
 565// ── Reactions (one-per-emoji toggle; open-write, gas is the throttle) ─
 566//
 567// A wallet may add each supported emoji at most once per post, and remove it
 568// again. Reactions carry no content (no DoS/GDPR surface like a post body), so
 569// there is no account-age gate — the per-(post,emoji,addr) dedup plus the gas
 570// cost of an on-chain tx are the throttle. Per-emoji COUNTS are aggregated
 571// off-chain by the indexer from the events below; the realm NEVER scans a
 572// post's reaction set, so the B1 render bounds are untouched.
 573
 574// reactionEmojis is the fixed on-chain reaction set. Changing it is a realm
 575// version bump (redeploy), so it is deliberately small and stable.
 576var reactionEmojis = []string{"👍", "❤️", "😂", "😮", "😢", "🔥", "🎉", "👀", "🚀"}
 577
 578func isReactionEmoji(e string) bool {
 579	for _, r := range reactionEmojis {
 580		if r == e {
 581			return true
 582		}
 583	}
 584	return false
 585}
 586
 587// reactionSubKey namespaces a reactor's entry within a post's reaction tree.
 588// The NUL separator cannot appear in a fixed-set emoji or a bech32 address, so
 589// (emoji, addr) round-trips unambiguously.
 590func reactionSubKey(emoji string, addr address) string {
 591	return emoji + "\x00" + addr.String()
 592}
 593
 594// HasReacted reports whether addr currently has the given emoji on the post.
 595// O(log n) point lookup — never a scan.
 596func HasReacted(id uint64, emoji string, addr address) bool {
 597	v, ok := reactions.Get(padID(id))
 598	if !ok {
 599		return false
 600	}
 601	_, exists := v.(*avl.Tree).Get(reactionSubKey(emoji, addr))
 602	return exists
 603}
 604
 605// AddReaction records the caller's emoji reaction. A repeat aborts (rather than
 606// no-op) so an optimistic client's double-tap cannot silently burn gas twice —
 607// the client gates the button on HasReacted / indexed state.
 608func AddReaction(cur realm, id uint64, emoji string) {
 609	assertNotPaused()
 610	if !isReactionEmoji(emoji) {
 611		panic("unsupported reaction")
 612	}
 613	caller := unsafe.PreviousRealm().Address()
 614	now := runtime.ChainHeight()
 615
 616	p := mustGetPost(id)
 617	if p.Deleted {
 618		panic("cannot react to a deleted post")
 619	}
 620	if p.Hidden {
 621		panic("cannot react to a hidden post")
 622	}
 623	touchFirstSeen(caller, now)
 624
 625	var tree *avl.Tree
 626	if v, ok := reactions.Get(padID(id)); ok {
 627		tree = v.(*avl.Tree)
 628	} else {
 629		tree = avl.NewTree()
 630	}
 631	key := reactionSubKey(emoji, caller)
 632	if _, already := tree.Get(key); already {
 633		panic("already reacted with this emoji")
 634	}
 635	tree.Set(key, true)
 636	reactions.Set(padID(id), tree)
 637
 638	chain.Emit("ReactionAdded",
 639		"postId", strconv.FormatUint(id, 10),
 640		"emoji", emoji,
 641		"by", caller.String(),
 642	)
 643}
 644
 645// RemoveReaction removes the caller's emoji reaction (toggle-off). Allowed even
 646// on a hidden/deleted post so a reactor can always retract; aborts if the caller
 647// had not reacted with that emoji.
 648func RemoveReaction(cur realm, id uint64, emoji string) {
 649	assertNotPaused()
 650	if !isReactionEmoji(emoji) {
 651		panic("unsupported reaction")
 652	}
 653	caller := unsafe.PreviousRealm().Address()
 654	mustGetPost(id) // aborts if the post never existed
 655
 656	v, ok := reactions.Get(padID(id))
 657	if !ok {
 658		panic("you have not reacted to this post")
 659	}
 660	tree := v.(*avl.Tree)
 661	key := reactionSubKey(emoji, caller)
 662	if _, exists := tree.Get(key); !exists {
 663		panic("you have not reacted with this emoji")
 664	}
 665	tree.Remove(key)
 666	if tree.Size() == 0 {
 667		reactions.Remove(padID(id)) // GC the empty subtree
 668	} else {
 669		reactions.Set(padID(id), tree)
 670	}
 671
 672	chain.Emit("ReactionRemoved",
 673		"postId", strconv.FormatUint(id, 10),
 674		"emoji", emoji,
 675		"by", caller.String(),
 676	)
 677}
 678
 679// ── Moderator role (W8.2) ────────────────────────────────────
 680// The owner grants/revokes moderator addresses that can ModRemovePost /
 681// UnhidePost. Fast (no owner-multisig-per-post) and reversible; grant/revoke
 682// stays owner-only. Every mutation is emitted for public audit.
 683
 684func assertCallerIsOwnerOrModerator() {
 685	caller := unsafe.PreviousRealm().Address()
 686	if caller == owner {
 687		return
 688	}
 689	if _, ok := moderators.Get(caller.String()); ok {
 690		return
 691	}
 692	panic("unauthorized: caller " + caller.String() + " is not the owner or a moderator")
 693}
 694
 695// IsModerator reports whether addr currently holds the moderator role.
 696func IsModerator(addr address) bool {
 697	_, ok := moderators.Get(addr.String())
 698	return ok
 699}
 700
 701// AddModerator grants the moderator role (owner only).
 702func AddModerator(cur realm, addr address) {
 703	assertCallerIsOwner()
 704	if addr == "" {
 705		panic("address cannot be empty")
 706	}
 707	moderators.Set(addr.String(), true)
 708	chain.Emit("ModeratorAdded", "moderator", addr.String(), "by", owner.String())
 709}
 710
 711// RemoveModerator revokes the moderator role (owner only).
 712func RemoveModerator(cur realm, addr address) {
 713	assertCallerIsOwner()
 714	if _, ok := moderators.Get(addr.String()); !ok {
 715		panic("address is not a moderator")
 716	}
 717	moderators.Remove(addr.String())
 718	chain.Emit("ModeratorRemoved", "moderator", addr.String(), "by", owner.String())
 719}
 720
 721// ── Moderation (owner OR moderator; W8.2 hot-key lever) ───────
 722
 723// ModRemovePost permanently hides a post by moderation. Emits an audited
 724// ModAction. The node is tombstoned for hard-GC like an author delete.
 725func ModRemovePost(cur realm, id uint64) {
 726	assertCallerIsOwnerOrModerator()
 727
 728	p := mustGetPost(id)
 729	if !p.Deleted {
 730		removeFromLiveIndexes(p)
 731		tombstones.Set(padID(id), true)
 732	}
 733	p.Deleted = true
 734	p.Hidden = true
 735	p.Body = ""
 736	p.MediaCIDs = nil
 737	posts.Set(padID(id), p)
 738
 739	chain.Emit("ModAction",
 740		"action", "remove",
 741		"postId", strconv.FormatUint(id, 10),
 742		"moderator", unsafe.PreviousRealm().Address().String(),
 743	)
 744}
 745
 746// UnhidePost clears flags and restores a flag-hidden post to the live set.
 747func UnhidePost(cur realm, id uint64) {
 748	assertCallerIsOwnerOrModerator()
 749
 750	p := mustGetPost(id)
 751	if p.Deleted {
 752		panic("cannot unhide a deleted post")
 753	}
 754	if !p.Hidden {
 755		panic("post is not hidden")
 756	}
 757	p.Hidden = false
 758	p.FlagCount = 0
 759	posts.Set(padID(id), p)
 760	flags.Remove(padID(id))
 761	addToLiveIndexes(p)
 762
 763	chain.Emit("ModAction",
 764		"action", "unhide",
 765		"postId", strconv.FormatUint(id, 10),
 766		"moderator", unsafe.PreviousRealm().Address().String(),
 767	)
 768}
 769
 770// ── Tombstone sweep (B2 hard-GC, ported from channels_v2) ────
 771
 772// SweepTombstones hard-removes up to `limit` soft-deleted posts, reclaiming
 773// AVL storage so post+delete spam cannot accrete permanent state.
 774// Permissionless by design (state-shrink hygiene primitive, not a refund
 775// path). Bounded + idempotent: keep `limit` small (1-10); re-running drains
 776// the next batch and stops at 0. Returns the number of posts swept.
 777//
 778// For a swept post that was a PARENT, its surviving replies' byParent links
 779// (`padID(parent):padID(child)`) would otherwise be orphaned under an id no
 780// longer in `posts` — a permanent leak plus a read inconsistency (replies
 781// still enumerable, getReplyCount() disagreeing). So the sweep range-deletes
 782// that byParent prefix; the reply posts themselves stay as standalone live
 783// content (their parent was removed by its own author). Bounded because the
 784// live reply set per post is capped at MaxRepliesPerPost — but a reply-heavy
 785// parent makes one sweep step do up to that many removes, so keep `limit`
 786// small (1) when draining known reply-heavy tombstones.
 787func SweepTombstones(cur realm, limit int) int {
 788	assertNotPaused()
 789	if limit <= 0 {
 790		return 0
 791	}
 792
 793	// Collect the oldest `limit` tombstoned ids (ascending), then mutate —
 794	// never remove from the tree we are iterating (AVL footgun).
 795	ids := []uint64{}
 796	tombstones.Iterate("", "", func(key string, _ interface{}) bool {
 797		ids = append(ids, idFromPadded(key))
 798		return len(ids) >= limit
 799	})
 800
 801	for _, id := range ids {
 802		// Drop this parent's remaining child links (if any) so no byParent
 803		// entry outlives its parent. Collect-then-remove over the prefix.
 804		prefix := padID(id) + ":"
 805		childKeys := []string{}
 806		byParent.Iterate(prefix, prefix+"\xff", func(k string, _ interface{}) bool {
 807			childKeys = append(childKeys, k)
 808			return false
 809		})
 810		for _, k := range childKeys {
 811			byParent.Remove(k)
 812		}
 813
 814		posts.Remove(padID(id))
 815		flags.Remove(padID(id))
 816		replyCount.Remove(padID(id))
 817		tombstones.Remove(padID(id))
 818	}
 819
 820	if len(ids) > 0 {
 821		chain.Emit("TombstonesSwept", "count", strconv.Itoa(len(ids)))
 822	}
 823	return len(ids)
 824}
 825
 826// GetTombstoneCount returns how many soft-deleted posts await hard-GC.
 827func GetTombstoneCount() int { return tombstones.Size() }
 828
 829// ── JSON reads (vm/qeval; cursor-paginated, O(limit) at any depth) ──
 830
 831// jsonEscape escapes a string for embedding in a JSON string literal.
 832func jsonEscape(s string) string {
 833	var sb strings.Builder
 834	for _, c := range s {
 835		switch c {
 836		case '"':
 837			sb.WriteString("\\\"")
 838		case '\\':
 839			sb.WriteString("\\\\")
 840		case '\n':
 841			sb.WriteString("\\n")
 842		case '\r':
 843			sb.WriteString("\\r")
 844		case '\t':
 845			sb.WriteString("\\t")
 846		default:
 847			if c < 0x20 {
 848				continue // drop raw control chars (ufmt has no \uXXXX support)
 849			}
 850			sb.WriteRune(c)
 851		}
 852	}
 853	return sb.String()
 854}
 855
 856// postJSON is the qeval read fallback. Note it exposes `mediaCids` (P2) and
 857// `repostOf` (P1) which the CANONICAL indexed path (chain.Emit → backend →
 858// feed_rpc → UI) does NOT carry yet — PostCreated emits neither, the proto
 859// reserves repost_of, and there is no media column. A future dev wiring the
 860// qeval fallback must not assume those two are plumbed end-to-end.
 861func postJSON(p *Post) string {
 862	var media strings.Builder
 863	media.WriteString("[")
 864	for i, cid := range p.MediaCIDs {
 865		if i > 0 {
 866			media.WriteString(",")
 867		}
 868		media.WriteString("\"" + jsonEscape(cid) + "\"")
 869	}
 870	media.WriteString("]")
 871
 872	return ufmt.Sprintf(
 873		`{"id":%d,"author":"%s","body":"%s","mediaCids":%s,"replyTo":%d,"repostOf":%d,"blockH":%d,"editedAt":%d,"flagCount":%d,"hidden":%s,"deleted":%s,"replies":%d}`,
 874		p.ID, p.Author.String(), jsonEscape(p.Body), media.String(),
 875		p.ReplyTo, p.RepostOf, p.BlockH, p.EditedAt, p.FlagCount,
 876		boolStr(p.Hidden), boolStr(p.Deleted), getReplyCount(p.ID),
 877	)
 878}
 879
 880func boolStr(b bool) string {
 881	if b {
 882		return "true"
 883	}
 884	return "false"
 885}
 886
 887func clampLimit(limit int) int {
 888	if limit <= 0 {
 889		return FeedPageSize
 890	}
 891	if limit > MaxPageLimit {
 892		return MaxPageLimit
 893	}
 894	return limit
 895}
 896
 897// GetPostJSON returns one post (any state — the indexer needs tombstones too).
 898func GetPostJSON(id uint64) string {
 899	p, ok := getPost(id)
 900	if !ok {
 901		return "null"
 902	}
 903	return postJSON(p)
 904}
 905
 906// listWindow walks `tree` REVERSE (newest first) starting below cursorKey
 907// ("" = from the newest), collecting up to limit ids via extract.
 908func listWindow(tree *avl.Tree, cursorKey string, limit int, extract func(key string) uint64) []uint64 {
 909	ids := []uint64{}
 910	count := 0
 911	tree.ReverseIterate("", cursorKey, func(key string, _ interface{}) bool {
 912		id := extract(key)
 913		if id != 0 {
 914			ids = append(ids, id)
 915			count++
 916		}
 917		return count >= limit
 918	})
 919	return ids
 920}
 921
 922func idFromPadded(key string) uint64 {
 923	n, err := strconv.ParseUint(key, 10, 64)
 924	if err != nil {
 925		return 0
 926	}
 927	return n
 928}
 929
 930func idFromComposite(key string) uint64 {
 931	i := strings.LastIndexByte(key, ':')
 932	if i < 0 {
 933		return 0
 934	}
 935	return idFromPadded(key[i+1:])
 936}
 937
 938func idsToJSON(ids []uint64) string {
 939	var sb strings.Builder
 940	sb.WriteString("[")
 941	for i, id := range ids {
 942		if i > 0 {
 943			sb.WriteString(",")
 944		}
 945		if p, ok := getPost(id); ok {
 946			sb.WriteString(postJSON(p))
 947		}
 948	}
 949	sb.WriteString("]")
 950	return sb.String()
 951}
 952
 953// ListFeedJSON returns the newest live posts strictly older than cursor
 954// (cursor == 0 → from the top). Pass the last id of the previous window as
 955// the next cursor.
 956//
 957// ReverseIterate treats BOTH bounds as inclusive (avl/v0 TraverseInRange), so
 958// "strictly older than cursor" = end bound padID(cursor-1): ids are integers,
 959// keys are fixed-width, hence key < padID(cursor) ⟺ key ≤ padID(cursor-1).
 960func ListFeedJSON(cursor uint64, limit int) string {
 961	limit = clampLimit(limit)
 962	cursorKey := ""
 963	if cursor != 0 {
 964		cursorKey = padID(cursor - 1)
 965	}
 966	return idsToJSON(listWindow(liveFeed, cursorKey, limit, idFromPadded))
 967}
 968
 969// ListUserJSON returns a user's newest live posts strictly older than cursor
 970// (same inclusive-bound rule as ListFeedJSON).
 971func ListUserJSON(addr string, cursor uint64, limit int) string {
 972	limit = clampLimit(limit)
 973	cursorKey := ""
 974	if cursor != 0 {
 975		cursorKey = addr + ":" + padID(cursor-1)
 976	} else {
 977		// End bound just past every "addr:XXXXXXXXXXXX" key: ':' + 0xff.
 978		cursorKey = addr + ":\xff"
 979	}
 980	ids := []uint64{}
 981	count := 0
 982	byAuthor.ReverseIterate(addr+":", cursorKey, func(key string, _ interface{}) bool {
 983		if id := idFromComposite(key); id != 0 {
 984			ids = append(ids, id)
 985			count++
 986		}
 987		return count >= limit
 988	})
 989	return idsToJSON(ids)
 990}
 991
 992// ListRepliesJSON returns a post's live replies, OLDEST first (conversation
 993// order), starting strictly after cursor (0 → from the beginning).
 994func ListRepliesJSON(parent uint64, cursor uint64, limit int) string {
 995	limit = clampLimit(limit)
 996	start := padID(parent) + ":"
 997	if cursor != 0 {
 998		start = parentKey(parent, cursor) + "\x00" // strictly after the cursor child
 999	}
1000	ids := []uint64{}
1001	count := 0
1002	byParent.Iterate(start, padID(parent)+":\xff", func(key string, _ interface{}) bool {
1003		if id := idFromComposite(key); id != 0 {
1004			ids = append(ids, id)
1005			count++
1006		}
1007		return count >= limit
1008	})
1009	return idsToJSON(ids)
1010}
1011
1012// GetStatsJSON returns realm-level counters (monotonic id is informational —
1013// no read iterates it).
1014func GetStatsJSON() string {
1015	return ufmt.Sprintf(`{"livePosts":%d,"nextPostId":%d,"tombstones":%d,"paused":%s}`,
1016		liveCount, nextPostID, tombstones.Size(), boolStr(paused))
1017}
1018
1019// ── Render (gnoweb human view; bounded — B1) ─────────────────
1020//
1021// Paths:
1022//   ""            → newest window (page 1)
1023//   page/K        → K-th newest window, K ≤ MaxRenderPage
1024//   user/ADDR     → ADDR's newest window
1025//   user/ADDR/K   → K-th window of ADDR's posts
1026//   post/ID       → one post + its newest reply window
1027//
1028// Every page scans at most MaxRenderPage*FeedPageSize live-index keys and
1029// fetches at most FeedPageSize posts — bounded regardless of feed size or
1030// churn (deleted/hidden posts are not in the live indexes at all).
1031
1032func Render(path string) string {
1033	if path == "" {
1034		return renderFeedPage(1)
1035	}
1036	if strings.HasPrefix(path, "page/") {
1037		k, err := strconv.ParseUint(strings.TrimPrefix(path, "page/"), 10, 64)
1038		if err != nil || k == 0 {
1039			return "# 404\nBad page"
1040		}
1041		return renderFeedPage(k)
1042	}
1043	if strings.HasPrefix(path, "user/") {
1044		rest := strings.TrimPrefix(path, "user/")
1045		addr := rest
1046		k := uint64(1)
1047		if i := strings.IndexByte(rest, '/'); i >= 0 {
1048			addr = rest[:i]
1049			n, err := strconv.ParseUint(rest[i+1:], 10, 64)
1050			if err != nil || n == 0 {
1051				return "# 404\nBad page"
1052			}
1053			k = n
1054		}
1055		return renderUserPage(addr, k)
1056	}
1057	if strings.HasPrefix(path, "post/") {
1058		id, err := strconv.ParseUint(strings.TrimPrefix(path, "post/"), 10, 64)
1059		if err != nil {
1060			return "# 404\nBad post id"
1061		}
1062		return renderPost(id)
1063	}
1064	return "# 404\nPage not found: " + path
1065}
1066
1067// collectPage walks a live index reverse and returns the ids for window k
1068// (1-based). Scan is bounded: k is capped at MaxRenderPage by callers.
1069func collectPage(tree *avl.Tree, prefix, endKey string, k uint64) []uint64 {
1070	skip := (k - 1) * FeedPageSize
1071	ids := []uint64{}
1072	seen := uint64(0)
1073	tree.ReverseIterate(prefix, endKey, func(key string, _ interface{}) bool {
1074		if seen >= skip {
1075			var id uint64
1076			if prefix == "" {
1077				id = idFromPadded(key)
1078			} else {
1079				id = idFromComposite(key)
1080			}
1081			if id != 0 {
1082				ids = append(ids, id)
1083			}
1084		}
1085		seen++
1086		return len(ids) >= FeedPageSize
1087	})
1088	return ids
1089}
1090
1091func renderPostLine(sb *strings.Builder, p *Post) {
1092	sb.WriteString(ufmt.Sprintf("**%s** (block %d)", truncAddr(p.Author), p.BlockH))
1093	if p.EditedAt != 0 {
1094		sb.WriteString(" *(edited)*")
1095	}
1096	if p.ReplyTo != 0 {
1097		sb.WriteString(ufmt.Sprintf(" — reply to [post %d](:post/%d)", p.ReplyTo, p.ReplyTo))
1098	}
1099	sb.WriteString("\n\n")
1100	sb.WriteString(sanitizeForRender(p.Body) + "\n\n")
1101	rc := getReplyCount(p.ID)
1102	sb.WriteString(ufmt.Sprintf("[permalink](:post/%d) | %d replies\n\n---\n\n", p.ID, rc))
1103}
1104
1105func renderFeedPage(k uint64) string {
1106	if k > MaxRenderPage {
1107		return ufmt.Sprintf("# Memba Feed\n\n*Pages beyond %d are not rendered — use the JSON cursor API (ListFeedJSON) or the Memba app.*\n", MaxRenderPage)
1108	}
1109	var sb strings.Builder
1110	sb.WriteString("# Memba Feed\n\n")
1111	sb.WriteString(ufmt.Sprintf("**Live posts:** %d\n\n", liveCount))
1112
1113	ids := collectPage(liveFeed, "", "", k)
1114	if len(ids) == 0 {
1115		sb.WriteString("*No posts on this page.*\n")
1116		return sb.String()
1117	}
1118	for _, id := range ids {
1119		if p, ok := getPost(id); ok {
1120			renderPostLine(&sb, p)
1121		}
1122	}
1123	sb.WriteString(ufmt.Sprintf("*page %d — older: `:page/%d`*\n", k, k+1))
1124	return sb.String()
1125}
1126
1127func renderUserPage(addr string, k uint64) string {
1128	if k > MaxRenderPage {
1129		return ufmt.Sprintf("# Feed — %s\n\n*Pages beyond %d are not rendered — use ListUserJSON or the Memba app.*\n", addr, MaxRenderPage)
1130	}
1131	var sb strings.Builder
1132	sb.WriteString(ufmt.Sprintf("# Feed — %s\n\n", addr))
1133	ids := collectPage(byAuthor, addr+":", addr+":\xff", k)
1134	if len(ids) == 0 {
1135		sb.WriteString("*No posts on this page.*\n")
1136		return sb.String()
1137	}
1138	for _, id := range ids {
1139		if p, ok := getPost(id); ok {
1140			renderPostLine(&sb, p)
1141		}
1142	}
1143	sb.WriteString(ufmt.Sprintf("*page %d — older: `:user/%s/%d`*\n", k, addr, k+1))
1144	return sb.String()
1145}
1146
1147func renderPost(id uint64) string {
1148	p, ok := getPost(id)
1149	if !ok {
1150		return "# 404\nPost not found"
1151	}
1152	// Same suppression rule as channels_v2: the direct path must not leak
1153	// hidden/deleted content.
1154	if p.Hidden || p.Deleted {
1155		return "# Post unavailable\n\n*This post has been hidden or removed.*\n"
1156	}
1157
1158	var sb strings.Builder
1159	sb.WriteString(ufmt.Sprintf("# Post %d\n\n", p.ID))
1160	renderPostLine(&sb, p)
1161
1162	// Newest reply window only (fixed size); full thread = JSON cursor API.
1163	rc := getReplyCount(p.ID)
1164	if rc > 0 {
1165		sb.WriteString("## Replies\n\n")
1166		if rc > FeedPageSize {
1167			sb.WriteString(ufmt.Sprintf("*Showing the %d newest of %d — full thread via ListRepliesJSON or the app.*\n\n", FeedPageSize, rc))
1168		}
1169		ids := collectPage(byParent, padID(p.ID)+":", padID(p.ID)+":\xff", 1)
1170		// collectPage walks reverse (newest first); show oldest→newest.
1171		for i := len(ids) - 1; i >= 0; i-- {
1172			if r, ok := getPost(ids[i]); ok {
1173				renderPostLine(&sb, r)
1174			}
1175		}
1176	}
1177	return sb.String()
1178}
1179
1180// ── Shared render helpers (ported verbatim from channels_v2) ─
1181
1182func truncAddr(addr address) string {
1183	s := string(addr)
1184	if len(s) > 13 {
1185		return s[:10] + "..."
1186	}
1187	return s
1188}
1189
1190// sanitizeForRender strips markdown-sensitive characters to prevent injection.
1191func sanitizeForRender(s string) string {
1192	var out strings.Builder
1193	for _, c := range s {
1194		switch c {
1195		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
1196			continue
1197		default:
1198			out.WriteRune(c)
1199		}
1200	}
1201	return out.String()
1202}