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

memba_feed_v1.gno

37.93 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/samcrew/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}