memba_feedback_v2.gno
40.29 Kb Β· 1342 lines
1package memba_feedback_v2
2
3// memba_dao_channels β Social feed realm for MembaDAO.
4//
5// Provides Discord-like channels with threads, replies, and moderation.
6// The Render() output MUST match the regex patterns in parser.ts (frontend).
7//
8// Render() contract (from parser.ts):
9//
10// Home β Render(""):
11// # BoardName
12// description
13// ## Channels
14// - [#name](:_channel/name) π’ (N threads)
15//
16// Channel β Render("channelName"):
17// # #channelName
18// ### [Title](:channel/id)
19// by g1addr... | N replies | block H
20//
21// Thread β Render("channel/id"):
22// # Title
23// body
24// ---
25// *Posted by g1addr at block H*
26// ## Replies
27// **g1addr...** (block H)
28// reply body
29// ---
30//
31// ACL β Render("__acl/channel"):
32// read:role1,role2
33// write:role1,role2,role3
34// type:text
35//
36// Security:
37// - All write operations require membership (AddMember by owner)
38// - PostThread/PostReply check caller's roles against channel WriteRoles
39// - CreateChannel/RemoveThread restricted to owner only
40// - FlagThread requires membership (prevents sybil flagging by non-members)
41// - EditThread/DeleteThread require membership + original author check
42//
43// Moderation: flag β threshold (3) β auto-hide β DAO vote β remove
44
45import (
46 "chain"
47 "chain/runtime"
48 "chain/runtime/unsafe"
49 "strconv"
50 "strings"
51
52 "gno.land/p/nt/avl/v0"
53 "gno.land/p/nt/ufmt/v0"
54)
55
56// ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββ
57
58const (
59 MaxPostLen = 5000
60 MaxTitleLen = 200
61 MaxChannels = 20
62 MaxThreadsPerChan = 500
63 FlagThreshold = 3 // flags before auto-hide
64
65 // AAA-1 B1 β render-DoS bounds for replies (mirrors MaxThreadsPerChan).
66 // renderThread must never iterate the monotonic reply counter (post+delete
67 // reply spam would inflate it without bound β unbounded render scan β
68 // thread permanently unrenderable past maxGasQuery). The live-reply index
69 // (replyLive/replyLiveIDs) caps the live set, and renderThread paginates a
70 // fixed window over it, so render cost is bounded regardless of churn.
71 MaxRepliesPerThread = 500 // live (non-deleted) replies per thread
72 ReplyPageSize = 50 // replies rendered per page
73)
74
75// ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββ
76
77type ChannelType string
78
79const (
80 ChannelText ChannelType = "text"
81 ChannelAnnouncements ChannelType = "announcements"
82 ChannelReadonly ChannelType = "readonly"
83)
84
85type Channel struct {
86 Name string
87 Description string
88 Type ChannelType
89 Archived bool
90 ReadRoles []string // roles that can read
91 WriteRoles []string // roles that can write
92}
93
94type Thread struct {
95 ID uint64
96 Channel string
97 Title string
98 Body string
99 Author address
100 BlockH int64
101 Edited bool
102 EditedAt int64
103 Deleted bool
104 FlagCount int
105 Hidden bool
106}
107
108type Reply struct {
109 ID uint64
110 ThreadID uint64
111 Channel string
112 Body string
113 Author address
114 BlockH int64
115 Edited bool
116}
117
118// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββ
119
120var (
121 channels *avl.Tree // name -> *Channel
122 threads *avl.Tree // "channel/id" -> *Thread
123 replies *avl.Tree // "channel/threadId/replyId" -> *Reply
124 threadCount *avl.Tree // channel -> uint64 (next thread ID, monotonic)
125 threadLive *avl.Tree // channel -> uint64 (currently live (non-deleted) threads)
126 threadLiveIDs *avl.Tree // channel -> []uint64 (live thread IDs, ascending) β bounds renderChannel
127 replyCount *avl.Tree // "channel/threadId" -> uint64 (next reply ID, monotonic)
128 replyLive *avl.Tree // "channel/threadId" -> uint64 (currently live (non-deleted) replies)
129 replyLiveIDs *avl.Tree // "channel/threadId" -> []uint64 (live reply IDs, ascending) β bounds renderThread
130 threadTomb *avl.Tree // channel -> []uint64 (soft-deleted thread IDs awaiting hard-GC via SweepTombstones) β B2
131 flags *avl.Tree // "channel/threadId" -> *avl.Tree (flagger addr -> bool)
132 channelOrder []string // ordered channel names
133
134 // Membership: address -> comma-separated roles (e.g., "admin,dev")
135 members *avl.Tree
136 paused bool
137 // Owner is set at package load time via OriginCaller() β this captures the
138 // deployer address (samcrew-core-test1 multisig on testnet).
139 owner = unsafe.OriginCaller()
140)
141
142func init() {
143 channels = avl.NewTree()
144 threads = avl.NewTree()
145 replies = avl.NewTree()
146 threadCount = avl.NewTree()
147 threadLive = avl.NewTree()
148 threadLiveIDs = avl.NewTree()
149 replyCount = avl.NewTree()
150 replyLive = avl.NewTree()
151 replyLiveIDs = avl.NewTree()
152 threadTomb = avl.NewTree()
153 flags = avl.NewTree()
154 channelOrder = []string{}
155 members = avl.NewTree()
156 members.Set(owner.String(), "admin")
157
158 // Public feedback board: anyone can post in the open channels (WriteRoles
159 // "*"); the frontend reads the "general" channel. "announcements" stays
160 // admin-write-only for official responses. Read is open everywhere.
161 addChannel("general", "Community feedback β anyone can post", ChannelText,
162 []string{"*"}, []string{"*"})
163 addChannel("feature-requests", "Propose and discuss new features", ChannelText,
164 []string{"*"}, []string{"*"})
165 addChannel("bugs", "Report bugs and issues", ChannelText,
166 []string{"*"}, []string{"*"})
167 addChannel("announcements", "Official Memba announcements β admin-write-only", ChannelAnnouncements,
168 []string{"*"}, []string{"admin"})
169}
170
171// ββ Channel Management βββββββββββββββββββββββββββββββββββββββ
172
173func addChannel(name, description string, ctype ChannelType, readRoles, writeRoles []string) {
174 ch := &Channel{
175 Name: name,
176 Description: description,
177 Type: ctype,
178 Archived: false,
179 ReadRoles: readRoles,
180 WriteRoles: writeRoles,
181 }
182 channels.Set(name, ch)
183 threadCount.Set(name, uint64(0))
184 threadLive.Set(name, uint64(0))
185 channelOrder = append(channelOrder, name)
186}
187
188// ββ Membership Management βββββββββββββββββββββββββββββββββββ
189
190// AddMember adds an address with specified roles. Only the owner can call this.
191// roles is a comma-separated list (e.g., "admin,dev" or "member").
192func AddMember(cur realm, addr address, roles string) {
193 assertCallerIsOwner()
194 if addr == "" {
195 panic("address cannot be empty")
196 }
197 if len(roles) == 0 {
198 panic("roles cannot be empty")
199 }
200 if !isValidRoles(roles) {
201 panic("invalid roles: must be comma-separated alphanumeric only")
202 }
203 members.Set(addr.String(), roles)
204
205 chain.Emit("MemberAdded",
206 "address", addr.String(),
207 "roles", roles,
208 )
209}
210
211// RemoveMember removes an address from the membership. Only the owner can call this.
212func RemoveMember(cur realm, addr address) {
213 assertCallerIsOwner()
214 if addr == owner {
215 panic("cannot remove owner")
216 }
217 if _, exists := members.Get(addr.String()); !exists {
218 panic("address is not a member: " + addr.String())
219 }
220 members.Remove(addr.String())
221
222 chain.Emit("MemberRemoved", "address", addr.String())
223}
224
225// UpdateMemberRoles updates the roles for an existing member. Only the owner can call this.
226func UpdateMemberRoles(cur realm, addr address, newRoles string) {
227 assertCallerIsOwner()
228 if _, exists := members.Get(addr.String()); !exists {
229 panic("address is not a member: " + addr.String())
230 }
231 if len(newRoles) == 0 {
232 panic("roles cannot be empty")
233 }
234 if !isValidRoles(newRoles) {
235 panic("invalid roles: must be comma-separated alphanumeric only")
236 }
237 members.Set(addr.String(), newRoles)
238
239 chain.Emit("MemberRolesUpdated", "address", addr.String(), "roles", newRoles)
240}
241
242// TransferOwnership transfers realm ownership to a new address. Only the current owner can call this.
243// The new owner gets "admin" added to their existing roles (not overwritten).
244// The old owner is demoted from "admin" to "member".
245func TransferOwnership(cur realm, newOwner address) {
246 assertCallerIsOwner()
247 if newOwner == "" {
248 panic("address cannot be empty")
249 }
250 if newOwner == owner {
251 panic("new owner is the same as current owner")
252 }
253 prevOwner := owner
254 // Preserve existing roles for new owner, ensure they have admin
255 existingRoles := GetMemberRoles(newOwner)
256 if existingRoles == "" {
257 members.Set(newOwner.String(), "admin")
258 } else if !hasRole(newOwner, "admin") {
259 members.Set(newOwner.String(), existingRoles+",admin")
260 }
261 // Remove "admin" from old owner's roles, keep other roles.
262 // If no roles remain, set to "member".
263 oldRoles := GetMemberRoles(owner)
264 var kept []string
265 for _, r := range strings.Split(oldRoles, ",") {
266 r = strings.TrimSpace(r)
267 if r != "admin" && r != "" {
268 kept = append(kept, r)
269 }
270 }
271 if len(kept) == 0 {
272 members.Set(owner.String(), "member")
273 } else {
274 members.Set(owner.String(), strings.Join(kept, ","))
275 }
276 owner = newOwner
277
278 chain.Emit("OwnershipTransferred",
279 "previousOwner", prevOwner.String(),
280 "newOwner", newOwner.String(),
281 )
282}
283
284// SyncMembers allows the owner to batch-sync membership from the DAO.
285// addresses and rolesList are comma-separated, with roles pipe-delimited per address.
286// Example: SyncMembers("g1a,g1b", "admin,dev|member")
287func SyncMembers(cur realm, addresses string, rolesList string) {
288 assertCallerIsOwner()
289 addrs := strings.Split(addresses, ",")
290 roles := strings.Split(rolesList, "|")
291 if len(addrs) != len(roles) {
292 panic("addresses and roles count mismatch")
293 }
294 synced := 0
295 for i, addr := range addrs {
296 addr = strings.TrimSpace(addr)
297 if addr == "" {
298 continue
299 }
300 role := strings.TrimSpace(roles[i])
301 if !isValidRoles(role) {
302 panic("invalid roles in entry " + strconv.Itoa(i))
303 }
304 members.Set(addr, role)
305 synced++
306 }
307
308 chain.Emit("MembersSynced", "count", strconv.Itoa(synced))
309}
310
311// PurgeNonMembers removes all members not in the provided comma-separated list.
312// The owner is never purged.
313func PurgeNonMembers(cur realm, keepAddresses string) {
314 assertCallerIsOwner()
315 keep := make(map[string]bool)
316 for _, addr := range strings.Split(keepAddresses, ",") {
317 keep[strings.TrimSpace(addr)] = true
318 }
319 keep[owner.String()] = true // owner is never purged
320 var toRemove []string
321 members.Iterate("", "", func(key string, _ interface{}) bool {
322 if !keep[key] {
323 toRemove = append(toRemove, key)
324 }
325 return false
326 })
327 for _, addr := range toRemove {
328 members.Remove(addr)
329 }
330
331 chain.Emit("MembersPurged", "count", strconv.Itoa(len(toRemove)))
332}
333
334// IsMember returns whether an address is a member.
335func IsMember(addr address) bool {
336 _, exists := members.Get(addr.String())
337 return exists
338}
339
340// GetMemberRoles returns the roles for a member (comma-separated) or empty string.
341func GetMemberRoles(addr address) string {
342 val, exists := members.Get(addr.String())
343 if !exists {
344 return ""
345 }
346 return val.(string)
347}
348
349// GetOwner returns the current realm owner address.
350func GetOwner() address {
351 return owner
352}
353
354// ββ Emergency Pause ββββββββββββββββββββββββββββββββββββββββ
355//
356// Pause policy (AAA-1 B3 β one policy, enforced everywhere):
357// While paused, every member-facing content write is blocked via
358// assertNotPaused() β PostThread, PostReply, EditThread, DeleteThread/Reply,
359// FlagThread, RemoveThread/Reply, and SweepTombstones.
360//
361// Owner-only governance (AddMember/RemoveMember/UpdateMemberRoles/SyncMembers/
362// PurgeNonMembers/TransferOwnership/CreateChannel) is intentionally NOT gated:
363// pause halts user activity during an incident, but the owner must stay able to
364// fix membership/ownership while paused. This realm holds no funds, so there is
365// no value-exit exemption to carve out.
366
367func assertNotPaused() {
368 if paused {
369 panic("realm is paused β emergency maintenance")
370 }
371}
372
373func PauseRealm(cur realm) {
374 assertCallerIsOwner()
375 paused = true
376 chain.Emit("RealmPaused", "by", owner.String())
377}
378
379func UnpauseRealm(cur realm) {
380 assertCallerIsOwner()
381 paused = false
382 chain.Emit("RealmUnpaused", "by", owner.String())
383}
384
385func IsPaused() bool { return paused }
386
387// ββ Channel Management βββββββββββββββββββββββββββββββββββββββ
388
389// CreateChannel adds a new channel. Only the owner (admin) can create channels.
390func CreateChannel(cur realm, name, description string, ctype string) {
391 assertCallerIsOwner()
392
393 if len(channelOrder) >= MaxChannels {
394 panic(ufmt.Sprintf("max channels reached: %d", MaxChannels))
395 }
396 if _, exists := channels.Get(name); exists {
397 panic("channel already exists: " + name)
398 }
399 if !isValidChannelName(name) {
400 panic("invalid channel name: must be 1-50 lowercase alphanumeric characters or hyphens, no leading underscore")
401 }
402
403 ct := ChannelText
404 switch ctype {
405 case "announcements":
406 ct = ChannelAnnouncements
407 case "readonly":
408 ct = ChannelReadonly
409 }
410
411 addChannel(name, description, ct,
412 []string{"admin", "dev", "ops", "member"},
413 []string{"admin", "dev", "ops", "member"})
414
415 chain.Emit("ChannelCreated", "name", name, "type", string(ct))
416}
417
418// ββ Post Management βββββββββββββββββββββββββββββββββββββββββ
419
420// PostThread creates a new thread in a channel.
421// Caller must be a member with a role listed in the channel's WriteRoles.
422func PostThread(cur realm, channel, title, body string) uint64 {
423 assertNotPaused()
424 caller := unsafe.PreviousRealm().Address()
425
426 // Validate membership and channel write access
427 ch := getChannel(channel)
428 assertCallerHasWriteAccess(caller, ch)
429 if ch.Archived {
430 panic("channel is archived")
431 }
432 // Cap on LIVE (non-deleted) threads so deleted threads free up slots.
433 if getLiveThreadCount(channel) >= MaxThreadsPerChan {
434 panic(ufmt.Sprintf("channel live thread limit reached: %d β delete old threads to make room", MaxThreadsPerChan))
435 }
436 if len(title) == 0 || len(title) > MaxTitleLen {
437 panic(ufmt.Sprintf("title must be 1-%d characters", MaxTitleLen))
438 }
439 if len(body) > MaxPostLen {
440 panic(ufmt.Sprintf("body too long: %d/%d chars", len(body), MaxPostLen))
441 }
442
443 // Get next ID
444 nextID := getThreadCount(channel)
445 key := channel + "/" + strconv.FormatUint(nextID, 10)
446
447 t := &Thread{
448 ID: nextID,
449 Channel: channel,
450 Title: title,
451 Body: body,
452 Author: caller,
453 BlockH: runtime.ChainHeight(),
454 }
455 threads.Set(key, t)
456 threadCount.Set(channel, nextID+1)
457 threadLive.Set(channel, getLiveThreadCount(channel)+1)
458 addLiveThreadID(channel, nextID)
459 replyCount.Set(key, uint64(0))
460
461 chain.Emit("ThreadPosted",
462 "channel", channel,
463 "threadId", strconv.FormatUint(nextID, 10),
464 "author", caller.String(),
465 )
466
467 return nextID
468}
469
470// PostReply adds a reply to a thread.
471// Caller must be a member with a role listed in the channel's WriteRoles.
472func PostReply(cur realm, channel string, threadID uint64, body string) {
473 assertNotPaused()
474 caller := unsafe.PreviousRealm().Address()
475
476 // Validate membership and channel write access
477 ch := getChannel(channel)
478 assertCallerHasWriteAccess(caller, ch)
479
480 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
481 tval, texists := threads.Get(threadKey)
482 if !texists {
483 panic("thread not found")
484 }
485 thread := tval.(*Thread)
486 if thread.Deleted {
487 panic("cannot reply to a deleted thread")
488 }
489 if thread.Hidden {
490 panic("cannot reply to a hidden thread")
491 }
492
493 if len(body) == 0 || len(body) > MaxPostLen {
494 panic(ufmt.Sprintf("reply must be 1-%d characters", MaxPostLen))
495 }
496
497 // B1: bound the LIVE reply set so renderThread stays under the gas budget.
498 // Deleting replies frees slots (mirrors MaxThreadsPerChan / getLiveThreadCount).
499 if getLiveReplyCount(threadKey) >= MaxRepliesPerThread {
500 panic(ufmt.Sprintf("thread reply limit reached: %d β older replies must be removed", MaxRepliesPerThread))
501 }
502
503 nextReplyID := getReplyCount(threadKey)
504 replyKey := threadKey + "/" + strconv.FormatUint(nextReplyID, 10)
505
506 r := &Reply{
507 ID: nextReplyID,
508 ThreadID: threadID,
509 Channel: channel,
510 Body: body,
511 Author: caller,
512 BlockH: runtime.ChainHeight(),
513 }
514 replies.Set(replyKey, r)
515 replyCount.Set(threadKey, nextReplyID+1)
516 // Track the live reply so renderThread iterates only live IDs (bounded set),
517 // never the monotonic counter.
518 addLiveReplyID(threadKey, nextReplyID)
519 replyLive.Set(threadKey, getLiveReplyCount(threadKey)+1)
520
521 chain.Emit("ReplyPosted",
522 "channel", channel,
523 "threadId", strconv.FormatUint(threadID, 10),
524 "replyId", strconv.FormatUint(nextReplyID, 10),
525 "author", caller.String(),
526 )
527}
528
529// dropReply hard-removes a reply: deletes the node (storage reclaimed β B2),
530// drops it from the live index, and decrements the live count. Shared by
531// DeleteReply and RemoveReply. renderThread only walks the live index, so a
532// removed reply is never looked up β no tombstone is needed.
533func dropReply(threadKey, replyKey string, replyID uint64) {
534 replies.Remove(replyKey)
535 removeLiveReplyID(threadKey, replyID)
536 if live := getLiveReplyCount(threadKey); live > 0 {
537 replyLive.Set(threadKey, live-1)
538 }
539}
540
541// DeleteReply lets the reply's author delete it. Caller must be a member and the
542// original author. The reply node is hard-removed (B2 state-shrink) and a slot is
543// freed under MaxRepliesPerThread (B1).
544func DeleteReply(cur realm, channel string, threadID, replyID uint64) {
545 assertNotPaused()
546 caller := unsafe.PreviousRealm().Address()
547 assertCallerIsMember(caller)
548
549 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
550 replyKey := threadKey + "/" + strconv.FormatUint(replyID, 10)
551 rval, exists := replies.Get(replyKey)
552 if !exists {
553 panic("reply not found")
554 }
555 if rval.(*Reply).Author != caller {
556 panic("only the author can delete")
557 }
558
559 dropReply(threadKey, replyKey, replyID)
560
561 chain.Emit("ReplyDeleted",
562 "channel", channel,
563 "threadId", strconv.FormatUint(threadID, 10),
564 "replyId", strconv.FormatUint(replyID, 10),
565 "author", caller.String(),
566 )
567}
568
569// RemoveReply permanently removes a reply (moderation β admin role only). Hard-
570// removes the node like DeleteReply, bounding renderThread (B1) + reclaiming
571// storage (B2).
572func RemoveReply(cur realm, channel string, threadID, replyID uint64) {
573 assertCallerIsAdminRole()
574 caller := unsafe.PreviousRealm().Address()
575
576 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
577 replyKey := threadKey + "/" + strconv.FormatUint(replyID, 10)
578 if _, exists := replies.Get(replyKey); !exists {
579 panic("reply not found")
580 }
581
582 dropReply(threadKey, replyKey, replyID)
583
584 chain.Emit("ReplyRemoved",
585 "channel", channel,
586 "threadId", strconv.FormatUint(threadID, 10),
587 "replyId", strconv.FormatUint(replyID, 10),
588 "admin", caller.String(),
589 )
590}
591
592// EditThread allows the original author to edit their thread. Caller must be a member.
593func EditThread(cur realm, channel string, threadID uint64, newBody string) {
594 assertNotPaused()
595 caller := unsafe.PreviousRealm().Address()
596 assertCallerIsMember(caller)
597
598 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
599 val, exists := threads.Get(threadKey)
600 if !exists {
601 panic("thread not found")
602 }
603
604 t := val.(*Thread)
605 if t.Author != caller {
606 panic("only the author can edit")
607 }
608 if t.Deleted {
609 panic("cannot edit a deleted thread")
610 }
611 if t.Hidden {
612 panic("cannot edit a hidden thread")
613 }
614 if len(newBody) > MaxPostLen {
615 panic("body too long")
616 }
617
618 t.Body = newBody
619 t.Edited = true
620 t.EditedAt = runtime.ChainHeight()
621 threads.Set(threadKey, t)
622
623 chain.Emit("ThreadEdited",
624 "channel", channel,
625 "threadId", strconv.FormatUint(threadID, 10),
626 "author", caller.String(),
627 )
628}
629
630// DeleteThread soft-deletes a thread (marks as deleted). Caller must be a member and the original author.
631func DeleteThread(cur realm, channel string, threadID uint64) {
632 assertNotPaused()
633 caller := unsafe.PreviousRealm().Address()
634 assertCallerIsMember(caller)
635
636 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
637 val, exists := threads.Get(threadKey)
638 if !exists {
639 panic("thread not found")
640 }
641
642 t := val.(*Thread)
643 if t.Author != caller {
644 panic("only the author can delete")
645 }
646 if t.Deleted {
647 panic("thread already deleted")
648 }
649
650 t.Deleted = true
651 t.Title = "[Deleted]"
652 t.Body = ""
653 threads.Set(threadKey, t)
654 // Free a slot for new threads
655 live := getLiveThreadCount(channel)
656 if live > 0 {
657 threadLive.Set(channel, live-1)
658 }
659 removeLiveThreadID(channel, threadID)
660 enqueueThreadTomb(channel, threadID) // B2: queue for hard-GC via SweepTombstones
661
662 chain.Emit("ThreadDeleted",
663 "channel", channel,
664 "threadId", strconv.FormatUint(threadID, 10),
665 "author", caller.String(),
666 )
667}
668
669// ββ Moderation ββββββββββββββββββββββββββββββββββββββββββββββ
670
671// FlagThread flags a thread for moderation review.
672// After FlagThreshold flags, the thread is auto-hidden.
673// Caller must be a member (any role) to flag content.
674func FlagThread(cur realm, channel string, threadID uint64) {
675 assertNotPaused()
676 caller := unsafe.PreviousRealm().Address()
677 assertCallerIsMember(caller)
678 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
679
680 val, exists := threads.Get(threadKey)
681 if !exists {
682 panic("thread not found")
683 }
684
685 t := val.(*Thread)
686 if t.Deleted {
687 panic("cannot flag a deleted thread")
688 }
689 if t.Hidden {
690 panic("thread is already hidden")
691 }
692
693 // Track unique flaggers
694 var flagTree *avl.Tree
695 if fval, fexists := flags.Get(threadKey); fexists {
696 flagTree = fval.(*avl.Tree)
697 } else {
698 flagTree = avl.NewTree()
699 }
700
701 if _, already := flagTree.Get(caller.String()); already {
702 panic("already flagged")
703 }
704 flagTree.Set(caller.String(), true)
705 flags.Set(threadKey, flagTree)
706
707 // Update thread flag count and auto-hide.
708 // Dynamic threshold: max(FlagThreshold, 5% of members), scales with DAO size.
709 t.FlagCount = flagTree.Size()
710 dynamicThreshold := FlagThreshold
711 fivePct := members.Size() / 20
712 if fivePct > dynamicThreshold {
713 dynamicThreshold = fivePct
714 }
715 wasHidden := t.Hidden
716 if t.FlagCount >= dynamicThreshold {
717 t.Hidden = true
718 }
719 threads.Set(threadKey, t)
720
721 chain.Emit("ThreadFlagged",
722 "channel", channel,
723 "threadId", strconv.FormatUint(threadID, 10),
724 "flagger", caller.String(),
725 "flagCount", strconv.Itoa(t.FlagCount),
726 )
727 if !wasHidden && t.Hidden {
728 chain.Emit("ThreadAutoHidden",
729 "channel", channel,
730 "threadId", strconv.FormatUint(threadID, 10),
731 )
732 }
733}
734
735// UnhideThread allows the owner or an admin to clear flags and un-hide a thread.
736func UnhideThread(cur realm, channel string, threadID uint64) {
737 assertCallerIsAdminRole()
738 caller := unsafe.PreviousRealm().Address()
739 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
740
741 val, exists := threads.Get(threadKey)
742 if !exists {
743 panic("thread not found")
744 }
745 t := val.(*Thread)
746 t.Hidden = false
747 t.FlagCount = 0
748 threads.Set(threadKey, t)
749 // Clear flag tree
750 flags.Remove(threadKey)
751
752 chain.Emit("ThreadUnhidden",
753 "channel", channel,
754 "threadId", strconv.FormatUint(threadID, 10),
755 "admin", caller.String(),
756 )
757}
758
759// RemoveThread permanently removes a thread (moderation action β admin role only).
760func RemoveThread(cur realm, channel string, threadID uint64) {
761 assertCallerIsAdminRole()
762 caller := unsafe.PreviousRealm().Address()
763 threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
764 val, exists := threads.Get(threadKey)
765 if !exists {
766 panic("thread not found")
767 }
768
769 t := val.(*Thread)
770 if !t.Deleted {
771 live := getLiveThreadCount(channel)
772 if live > 0 {
773 threadLive.Set(channel, live-1)
774 }
775 removeLiveThreadID(channel, threadID)
776 enqueueThreadTomb(channel, threadID) // B2: queue for hard-GC (skip if already tombstoned)
777 }
778 t.Deleted = true
779 t.Hidden = true
780 t.Title = "[Removed by moderation]"
781 t.Body = ""
782 threads.Set(threadKey, t)
783
784 chain.Emit("ThreadRemoved",
785 "channel", channel,
786 "threadId", strconv.FormatUint(threadID, 10),
787 "admin", caller.String(),
788 )
789}
790
791// SweepTombstones hard-removes up to `limit` soft-deleted threads in a channel
792// (and all of their remaining reply state), reclaiming AVL storage so that
793// post+delete spam can no longer accrete permanent state (B2).
794//
795// Permissionless GC by design: anyone may call it. Released storage deposits go
796// to the calling tx per the chain's deposit policy (an explicit caller bounty);
797// on restricted-denom chains (e.g. test12/test13 `restricted_denoms=["ugnot"]`)
798// they route to the StorageFeeCollector instead β so this is a state-shrink/
799// hygiene primitive, not a user-refund path (see plan B2 / Q11).
800//
801// Bounded + idempotent: each soft-deleted thread holds at most
802// MaxRepliesPerThread reply nodes, so keep `limit` small (1β5) to stay well
803// within block gas; re-running drains the next batch and stops at 0.
804// Returns the number of threads swept.
805func SweepTombstones(cur realm, channel string, limit int) int {
806 assertNotPaused()
807 if limit <= 0 {
808 return 0
809 }
810
811 tomb := getThreadTomb(channel)
812 n := limit
813 if n > len(tomb) {
814 n = len(tomb)
815 }
816
817 for i := 0; i < n; i++ {
818 threadKey := channel + "/" + strconv.FormatUint(tomb[i], 10)
819 // Collect-then-remove: read the live reply IDs (a stored slice) and remove
820 // each reply node β we never iterate the tree we mutate (AVL footgun).
821 for _, rid := range getLiveReplyIDs(threadKey) {
822 replies.Remove(threadKey + "/" + strconv.FormatUint(rid, 10))
823 }
824 replyLiveIDs.Remove(threadKey)
825 replyLive.Remove(threadKey)
826 replyCount.Remove(threadKey)
827 flags.Remove(threadKey)
828 threads.Remove(threadKey)
829 }
830
831 // Drop the processed prefix; copy the tail into a fresh slice to avoid aliasing.
832 remaining := append([]uint64{}, tomb[n:]...)
833 if len(remaining) == 0 {
834 threadTomb.Remove(channel)
835 } else {
836 threadTomb.Set(channel, remaining)
837 }
838
839 if n > 0 {
840 chain.Emit("TombstonesSwept",
841 "channel", channel,
842 "count", strconv.Itoa(n),
843 )
844 }
845 return n
846}
847
848// GetTombstoneCount returns how many soft-deleted threads in a channel are still
849// awaiting hard-GC (read-only; lets ops/indexers decide when to call SweepTombstones).
850func GetTombstoneCount(channel string) int {
851 return len(getThreadTomb(channel))
852}
853
854// ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββ
855// CRITICAL: Output format MUST match parser.ts regex patterns.
856
857func Render(path string) string {
858 if path == "" {
859 return renderHome()
860 }
861 if strings.HasPrefix(path, "__acl/") {
862 channelName := strings.TrimPrefix(path, "__acl/")
863 return renderACL(channelName)
864 }
865 if strings.HasPrefix(path, "__member/") {
866 addr := strings.TrimPrefix(path, "__member/")
867 roles := GetMemberRoles(address(addr))
868 if roles == "" {
869 return "not found"
870 }
871 return "roles:" + roles
872 }
873 if strings.HasPrefix(path, "_channel/") {
874 channelName := strings.TrimPrefix(path, "_channel/")
875 return renderChannel(channelName)
876 }
877
878 // Check for "channel/threadId" pattern, optionally with a "?page=N" suffix
879 // for reply pagination (B1).
880 parts := strings.SplitN(path, "/", 2)
881 if len(parts) == 2 {
882 idPart := parts[1]
883 page := uint64(0)
884 if qi := strings.IndexByte(idPart, '?'); qi >= 0 {
885 page = parsePageQuery(idPart[qi+1:])
886 idPart = idPart[:qi]
887 }
888 threadID, err := strconv.ParseUint(idPart, 10, 64)
889 if err == nil {
890 return renderThread(parts[0], threadID, page)
891 }
892 }
893
894 // Try as channel name directly
895 if _, exists := channels.Get(path); exists {
896 return renderChannel(path)
897 }
898
899 return "# 404\nPage not found: " + path
900}
901
902// renderHome produces the board home page.
903// Format: parser.ts parseBoardHome() expects:
904// - [#name](:_channel/name) π’ (N threads)
905func renderHome() string {
906 var sb strings.Builder
907 sb.WriteString("# MembaDAO Channels\n\n")
908 sb.WriteString("Community discussion channels for the Memba ecosystem.\n\n")
909 sb.WriteString(ufmt.Sprintf("**Owner:** %s | **Members:** %d\n\n", owner, members.Size()))
910 sb.WriteString("## Channels\n\n")
911
912 for _, name := range channelOrder {
913 val, exists := channels.Get(name)
914 if !exists {
915 continue
916 }
917 ch := val.(*Channel)
918 if ch.Archived {
919 continue
920 }
921
922 count := getThreadCount(name)
923 typeIcon := ""
924 switch ch.Type {
925 case ChannelAnnouncements:
926 typeIcon = " π’"
927 case ChannelReadonly:
928 typeIcon = " π"
929 }
930
931 sb.WriteString(ufmt.Sprintf("- [#%s](:_channel/%s)%s (%d threads)\n",
932 name, name, typeIcon, count))
933 }
934
935 return sb.String()
936}
937
938// renderChannel produces a channel's thread list.
939// Format: parser.ts parseThreadList() expects:
940// ### [Title](:channel/id)
941// by g1addr... | N replies | block H
942func renderChannel(channelName string) string {
943 if _, exists := channels.Get(channelName); !exists {
944 return "# 404\nChannel not found: " + channelName
945 }
946
947 var sb strings.Builder
948 sb.WriteString(ufmt.Sprintf("# #%s\n\n", channelName))
949
950 // Iterate the LIVE thread-ID index (bounded by getLiveThreadCount), newest
951 // first. Never loop over the monotonic threadCount β a post+delete spam
952 // loop inflates it without bound (gas DoS); deleted IDs are not in this index.
953 ids := getLiveThreadIDs(channelName)
954 if len(ids) == 0 {
955 sb.WriteString("*No threads yet. Be the first to post!*\n")
956 return sb.String()
957 }
958
959 for i := len(ids) - 1; i >= 0; i-- {
960 threadKey := channelName + "/" + strconv.FormatUint(ids[i], 10)
961 val, exists := threads.Get(threadKey)
962 if !exists {
963 continue
964 }
965 t := val.(*Thread)
966 if t.Hidden || t.Deleted {
967 continue
968 }
969
970 // Count live (non-deleted) replies β matches what renderThread shows.
971 rCount := getLiveReplyCount(threadKey)
972 authorStr := truncAddr(t.Author)
973
974 sb.WriteString(ufmt.Sprintf("### [%s](:%s/%d)\n", sanitizeForRender(t.Title), channelName, t.ID))
975 sb.WriteString(ufmt.Sprintf("by %s | %d replies | block %d\n\n", authorStr, rCount, t.BlockH))
976 }
977
978 return sb.String()
979}
980
981// renderThread produces a single thread with replies.
982// Format: parser.ts parseThreadDetail() expects:
983// # Title
984// body
985// ---
986// *Posted by g1addr at block H* *(edited at block M)*
987// ## Replies
988// **g1addr...** (block H) *(edited)*
989// reply body
990// ---
991func renderThread(channelName string, threadID, page uint64) string {
992 threadKey := channelName + "/" + strconv.FormatUint(threadID, 10)
993 val, exists := threads.Get(threadKey)
994 if !exists {
995 return "# 404\nThread not found"
996 }
997
998 t := val.(*Thread)
999
1000 // Suppress content for hidden (flag-auto-hidden or admin-hidden) and
1001 // soft-deleted threads. renderChannel omits these from the list, but the
1002 // direct path Render("channel/id") must not leak the original title/body.
1003 if t.Hidden || t.Deleted {
1004 return "# Thread unavailable\n\n*This thread has been hidden or removed.*\n"
1005 }
1006
1007 var sb strings.Builder
1008 sb.WriteString(ufmt.Sprintf("# %s\n\n", sanitizeForRender(t.Title)))
1009 sb.WriteString(sanitizeForRender(t.Body) + "\n\n")
1010 sb.WriteString("---\n\n")
1011 sb.WriteString(ufmt.Sprintf("*Posted by %s at block %d*", string(t.Author), t.BlockH))
1012 if t.Edited {
1013 sb.WriteString(ufmt.Sprintf(" *(edited at block %d)*", t.EditedAt))
1014 }
1015 sb.WriteString("\n\n")
1016
1017 // B1: render only LIVE replies (bounded by MaxRepliesPerThread), paginated to
1018 // a fixed window so render cost is O(ReplyPageSize) regardless of total churn.
1019 // Never iterate the monotonic replyCount β that is the render-DoS surface.
1020 liveIDs := getLiveReplyIDs(threadKey) // ascending (oldestβnewest), <= MaxRepliesPerThread
1021 total := uint64(len(liveIDs))
1022 if total > 0 {
1023 totalPages := (total + ReplyPageSize - 1) / ReplyPageSize
1024 // page 1 = oldest window β¦ totalPages = newest. Default (0) and any
1025 // out-of-range value snap to the newest page (most-recent replies).
1026 if page == 0 || page > totalPages {
1027 page = totalPages
1028 }
1029 start := (page - 1) * ReplyPageSize
1030 end := start + ReplyPageSize
1031 if end > total {
1032 end = total
1033 }
1034
1035 sb.WriteString("## Replies\n\n")
1036 if totalPages > 1 {
1037 sb.WriteString(ufmt.Sprintf(
1038 "*Showing %dβ%d of %d β’ page %d/%d β older: `?page=%d`, newer: `?page=%d`*\n\n",
1039 start+1, end, total, page, totalPages, pageClamp(page-1, totalPages), pageClamp(page+1, totalPages)))
1040 }
1041
1042 for i := start; i < end; i++ {
1043 replyKey := threadKey + "/" + strconv.FormatUint(liveIDs[i], 10)
1044 rval, rexists := replies.Get(replyKey)
1045 if !rexists {
1046 continue // live index never points at a removed reply, but stay safe
1047 }
1048 r := rval.(*Reply)
1049 authorStr := truncAddr(r.Author)
1050 editStr := ""
1051 if r.Edited {
1052 editStr = " *(edited)*"
1053 }
1054 sb.WriteString(ufmt.Sprintf("**%s** (block %d)%s\n\n", authorStr, r.BlockH, editStr))
1055 sb.WriteString(sanitizeForRender(r.Body) + "\n\n")
1056 sb.WriteString("---\n\n")
1057 }
1058 }
1059
1060 return sb.String()
1061}
1062
1063// parsePageQuery extracts the page number from a "?page=N" query string (also
1064// tolerates extra &-separated params). Returns 0 (= default/newest) on absence
1065// or parse error.
1066func parsePageQuery(q string) uint64 {
1067 for _, kv := range strings.Split(q, "&") {
1068 if strings.HasPrefix(kv, "page=") {
1069 if n, err := strconv.ParseUint(strings.TrimPrefix(kv, "page="), 10, 64); err == nil {
1070 return n
1071 }
1072 }
1073 }
1074 return 0
1075}
1076
1077// pageClamp keeps a page link within [1, totalPages].
1078func pageClamp(p, totalPages uint64) uint64 {
1079 if p < 1 {
1080 return 1
1081 }
1082 if p > totalPages {
1083 return totalPages
1084 }
1085 return p
1086}
1087
1088// renderACL produces the ACL response for a channel.
1089// Format: parser.ts parseACL() expects:
1090// read:role1,role2
1091// write:role1,role2,role3
1092// type:text
1093func renderACL(channelName string) string {
1094 val, exists := channels.Get(channelName)
1095 if !exists {
1096 return "not found"
1097 }
1098 ch := val.(*Channel)
1099
1100 var sb strings.Builder
1101 sb.WriteString("read:" + strings.Join(ch.ReadRoles, ",") + "\n")
1102 sb.WriteString("write:" + strings.Join(ch.WriteRoles, ",") + "\n")
1103 sb.WriteString("type:" + string(ch.Type) + "\n")
1104
1105 return sb.String()
1106}
1107
1108// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
1109
1110func getChannel(name string) *Channel {
1111 val, exists := channels.Get(name)
1112 if !exists {
1113 panic("channel not found: " + name)
1114 }
1115 return val.(*Channel)
1116}
1117
1118func getThreadCount(channel string) uint64 {
1119 val, exists := threadCount.Get(channel)
1120 if !exists {
1121 return 0
1122 }
1123 return val.(uint64)
1124}
1125
1126// getLiveThreadCount returns the number of non-deleted threads in a channel.
1127// Unlike getThreadCount (monotonic ID counter), this decreases when threads
1128// are deleted, so deleted threads free up slots under MaxThreadsPerChan.
1129func getLiveThreadCount(channel string) uint64 {
1130 val, exists := threadLive.Get(channel)
1131 if !exists {
1132 return 0
1133 }
1134 return val.(uint64)
1135}
1136
1137// ββ Live thread-ID index βββββββββββββββββββββββββββββββββββββ
1138// renderChannel must iterate only live (non-deleted) thread IDs, never the
1139// monotonic threadCount: a member can post+delete in a loop to inflate
1140// threadCount without bound (the live cap only limits live threads), turning
1141// renderChannel into an unbounded O(threadCount) scan (gas DoS). This index
1142// stays bounded by getLiveThreadCount (<= MaxThreadsPerChan).
1143
1144func getLiveThreadIDs(channel string) []uint64 {
1145 if val, exists := threadLiveIDs.Get(channel); exists {
1146 return val.([]uint64)
1147 }
1148 return nil
1149}
1150
1151func addLiveThreadID(channel string, id uint64) {
1152 threadLiveIDs.Set(channel, append(getLiveThreadIDs(channel), id))
1153}
1154
1155func removeLiveThreadID(channel string, id uint64) {
1156 ids := getLiveThreadIDs(channel)
1157 for i, x := range ids {
1158 if x == id {
1159 threadLiveIDs.Set(channel, append(ids[:i], ids[i+1:]...))
1160 return
1161 }
1162 }
1163}
1164
1165// ββ Live reply-ID index ββββββββββββββββββββββββββββββββββββββ
1166// Same rationale as the thread index, one level down: renderThread must iterate
1167// only live reply IDs (bounded by MaxRepliesPerThread), never the monotonic
1168// replyCount, or a member can post+delete replies in a loop to make a thread
1169// unrenderable. Keyed by "channel/threadId".
1170
1171func getLiveReplyCount(threadKey string) uint64 {
1172 if val, exists := replyLive.Get(threadKey); exists {
1173 return val.(uint64)
1174 }
1175 return 0
1176}
1177
1178func getLiveReplyIDs(threadKey string) []uint64 {
1179 if val, exists := replyLiveIDs.Get(threadKey); exists {
1180 return val.([]uint64)
1181 }
1182 return nil
1183}
1184
1185func addLiveReplyID(threadKey string, id uint64) {
1186 replyLiveIDs.Set(threadKey, append(getLiveReplyIDs(threadKey), id))
1187}
1188
1189func removeLiveReplyID(threadKey string, id uint64) {
1190 ids := getLiveReplyIDs(threadKey)
1191 for i, x := range ids {
1192 if x == id {
1193 replyLiveIDs.Set(threadKey, append(ids[:i], ids[i+1:]...))
1194 return
1195 }
1196 }
1197}
1198
1199// ββ Thread tombstone queue (B2 hard-GC) ββββββββββββββββββββββ
1200// Soft-deleted thread IDs awaiting permanent removal by SweepTombstones. A queue
1201// (not a scan of the threads tree) keeps the sweep O(limit) β it never walks the
1202// unbounded set of past tombstones.
1203
1204func getThreadTomb(channel string) []uint64 {
1205 if val, exists := threadTomb.Get(channel); exists {
1206 return val.([]uint64)
1207 }
1208 return nil
1209}
1210
1211func enqueueThreadTomb(channel string, id uint64) {
1212 threadTomb.Set(channel, append(getThreadTomb(channel), id))
1213}
1214
1215func getReplyCount(threadKey string) uint64 {
1216 val, exists := replyCount.Get(threadKey)
1217 if !exists {
1218 return 0
1219 }
1220 return val.(uint64)
1221}
1222
1223func truncAddr(addr address) string {
1224 s := string(addr)
1225 if len(s) > 13 {
1226 return s[:10] + "..."
1227 }
1228 return s
1229}
1230
1231// isValidRoles validates a comma-separated role list contains only safe characters.
1232// Allowed: a-z, A-Z, 0-9, hyphen, comma. No spaces, no markdown chars, no null bytes.
1233func isValidRoles(s string) bool {
1234 if len(s) > 200 {
1235 return false
1236 }
1237 for _, c := range s {
1238 if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == ',') {
1239 return false
1240 }
1241 }
1242 return true
1243}
1244
1245// sanitizeForRender strips markdown-sensitive characters to prevent injection.
1246func sanitizeForRender(s string) string {
1247 var out strings.Builder
1248 for _, c := range s {
1249 switch c {
1250 case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
1251 continue
1252 default:
1253 out.WriteRune(c)
1254 }
1255 }
1256 return out.String()
1257}
1258
1259// ββ ACL Enforcement βββββββββββββββββββββββββββββββββββββββββ
1260
1261func assertCallerIsOwner() {
1262 caller := unsafe.PreviousRealm().Address()
1263 if caller != owner {
1264 panic("unauthorized: caller " + caller.String() + " is not the owner")
1265 }
1266}
1267
1268func assertCallerIsMember(caller address) {
1269 if _, exists := members.Get(caller.String()); !exists {
1270 panic("unauthorized: caller " + caller.String() + " is not a member")
1271 }
1272}
1273
1274// assertCallerHasWriteAccess checks that the caller is a member AND has at least
1275// one role that matches the channel's WriteRoles.
1276func assertCallerHasWriteAccess(caller address, ch *Channel) {
1277 // Public channels (WriteRoles contains "*") accept posts from any caller β
1278 // this is a public feedback board. Non-public channels keep role-based control
1279 // (e.g. announcements stays admin-only). Mainnet should add rate-limiting.
1280 for _, wr := range ch.WriteRoles {
1281 if wr == "*" {
1282 return
1283 }
1284 }
1285 rolesStr, exists := members.Get(caller.String())
1286 if !exists {
1287 panic("unauthorized: caller " + caller.String() + " is not a member")
1288 }
1289
1290 callerRoles := strings.Split(rolesStr.(string), ",")
1291 for _, cr := range callerRoles {
1292 cr = strings.TrimSpace(cr)
1293 for _, wr := range ch.WriteRoles {
1294 if cr == wr {
1295 return // Access granted
1296 }
1297 }
1298 }
1299
1300 panic("unauthorized: caller " + caller.String() + " lacks write access to channel " + ch.Name)
1301}
1302
1303func hasRole(caller address, role string) bool {
1304 rolesStr, exists := members.Get(caller.String())
1305 if !exists {
1306 return false
1307 }
1308 callerRoles := strings.Split(rolesStr.(string), ",")
1309 for _, cr := range callerRoles {
1310 if strings.TrimSpace(cr) == role {
1311 return true
1312 }
1313 }
1314 return false
1315}
1316
1317// assertCallerIsAdminRole checks that the caller is a member with the "admin" role.
1318// Used for moderation actions (RemoveThread, UnhideThread) so any admin can moderate,
1319// not just the single owner.
1320func assertCallerIsAdminRole() {
1321 caller := unsafe.PreviousRealm().Address()
1322 if !hasRole(caller, "admin") {
1323 panic("unauthorized: caller " + caller.String() + " does not have admin role")
1324 }
1325}
1326
1327// isValidChannelName validates that a channel name contains only
1328// lowercase alphanumeric characters and hyphens, and does not start with underscore.
1329func isValidChannelName(name string) bool {
1330 if len(name) == 0 || len(name) > 50 {
1331 return false
1332 }
1333 if name[0] == '_' {
1334 return false // Prevent collision with __acl/, __member/ paths
1335 }
1336 for _, c := range name {
1337 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
1338 return false
1339 }
1340 }
1341 return true
1342}