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

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