memba_reviews_v1.gno
20.79 Kb · 721 lines
1package memba_reviews_v1
2
3// Memba Reviews / Web-of-Trust realm.
4//
5// OPEN, on-chain ratings + reviews for any Memba subject (validator/candidate/
6// individual address, or an org/DAO realm path). Anyone with a wallet may post
7// ONE editable review per subject, react (like/dislike), reply (flat, one
8// level), and flag. A running net-likes reputation counter per author drives
9// ranking. Moderation = author delete (tombstone) + community flag + multisig
10// hide (soft-delete). Text is permanent on-chain; Hide only omits from reads.
11//
12// Reads: exported *JSON funcs queried via RPC vm/qeval (paginated). Render() is
13// a secondary human view for gnoweb.
14
15import (
16 "strconv"
17 "strings"
18
19 "gno.land/p/nt/avl/v0"
20 "gno.land/p/nt/ufmt/v0"
21
22 "chain"
23 "chain/runtime"
24 "chain/runtime/unsafe"
25)
26
27// ── Constants ────────────────────────────────────────────────
28const (
29 MaxBodyLen = 2000 // review body
30 MaxCommentLen = 1000 // comment body
31 MaxPageLimit = 100 // hard cap on any paginated read (DoS guard)
32
33 // Memba moderator multisig (samcrew-core-test1 on testnet). CONFIRM before deploy.
34 ModeratorAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
35)
36
37// ── Types ────────────────────────────────────────────────────
38type Review struct {
39 ID uint64
40 Subject string // g1… address OR realm path
41 Author address
42 Rating int // 1..5
43 Body string // optional, ≤ MaxBodyLen
44 CreatedAt int64 // block height
45 EditedAt int64 // block height of last edit (0 if never)
46 Hidden bool // multisig soft-delete / auto-hide
47 Deleted bool // author tombstone
48 Likes uint64
49 Dislikes uint64
50 FlagCount uint64
51}
52
53type Comment struct {
54 ID uint64
55 ReviewID uint64
56 Author address
57 Body string // ≤ MaxCommentLen
58 CreatedAt int64
59 EditedAt int64
60 Hidden bool
61 Deleted bool
62 Likes uint64
63 Dislikes uint64
64 FlagCount uint64
65}
66
67// ── State ────────────────────────────────────────────────────
68var (
69 reviews *avl.Tree // strID(id) -> *Review
70 comments *avl.Tree // strID(id) -> *Comment
71 subjectIndex *avl.Tree // subject -> []uint64 (review IDs, ascending) — bounds reads
72 commentIndex *avl.Tree // strID(reviewID) -> []uint64 (comment IDs, ascending)
73 authorSubject *avl.Tree // subject + "\x00" + author -> uint64 (reviewID) — one-per-pair
74 reactions *avl.Tree // strID(targetID) + "/" + addr -> "like"|"dislike"
75 flags *avl.Tree // strID(targetID) + "/" + addr -> true (one flag per acct/target)
76 reputation *avl.Tree // addr -> int64 (Σ likes−dislikes on their reviews+comments)
77 flaggedIDs *avl.Tree // strID(targetID) -> true (visible targets with ≥1 flag, for the mod dashboard)
78 nextID uint64
79)
80
81func init() {
82 reviews = avl.NewTree()
83 comments = avl.NewTree()
84 subjectIndex = avl.NewTree()
85 commentIndex = avl.NewTree()
86 authorSubject = avl.NewTree()
87 reactions = avl.NewTree()
88 flags = avl.NewTree()
89 reputation = avl.NewTree()
90 flaggedIDs = avl.NewTree()
91 nextID = 1
92}
93
94// ── Helpers ──────────────────────────────────────────────────
95func strID(id uint64) string { return strconv.FormatUint(id, 10) }
96
97func getReview(id uint64) (*Review, bool) {
98 v, ok := reviews.Get(strID(id))
99 if !ok {
100 return nil, false
101 }
102 return v.(*Review), true
103}
104
105func getComment(id uint64) (*Comment, bool) {
106 v, ok := comments.Get(strID(id))
107 if !ok {
108 return nil, false
109 }
110 return v.(*Comment), true
111}
112
113func assertModerator() {
114 caller := unsafe.PreviousRealm().Address()
115 if caller != address(ModeratorAddress) {
116 panic("unauthorized: moderator multisig only")
117 }
118}
119
120func getReputation(addr string) int64 {
121 if v, ok := reputation.Get(addr); ok {
122 return v.(int64)
123 }
124 return 0
125}
126
127func addReputation(addr string, delta int64) {
128 reputation.Set(addr, getReputation(addr)+delta)
129}
130
131func idList(t *avl.Tree, key string) []uint64 {
132 if v, ok := t.Get(key); ok {
133 return v.([]uint64)
134 }
135 return nil
136}
137
138// removeID returns ids with the first occurrence of target removed.
139func removeID(ids []uint64, target uint64) []uint64 {
140 out := make([]uint64, 0, len(ids))
141 removed := false
142 for _, id := range ids {
143 if !removed && id == target {
144 removed = true
145 continue
146 }
147 out = append(out, id)
148 }
149 return out
150}
151
152// sanitizeForRender strips markdown/HTML-sensitive chars from user strings used
153// inside Render() markdown (defense-in-depth; the frontend also DOMPurifies).
154// "&" is escaped FIRST to prevent entity-injection (e.g. "<" → "&lt;").
155func sanitizeForRender(s string) string {
156 r := strings.NewReplacer(
157 "&", "&",
158 "<", "<", ">", ">",
159 "[", "(", "]", ")",
160 "`", "'", "|", "/",
161 "\n", " ", "\r", " ",
162 )
163 return r.Replace(s)
164}
165
166// jsonEscape escapes a string for embedding in the realm's hand-built JSON.
167func jsonEscape(s string) string {
168 var b strings.Builder
169 for _, c := range s {
170 switch c {
171 case '"':
172 b.WriteString("\\\"")
173 case '\\':
174 b.WriteString("\\\\")
175 case '\n':
176 b.WriteString("\\n")
177 case '\r':
178 b.WriteString("\\r")
179 case '\t':
180 b.WriteString("\\t")
181 default:
182 if c < 0x20 {
183 const hexDigits = "0123456789abcdef"
184 b.WriteString("\\u00")
185 b.WriteByte(hexDigits[int((c>>4)&0xf)])
186 b.WriteByte(hexDigits[int(c&0xf)])
187 } else {
188 b.WriteRune(c)
189 }
190 }
191 }
192 return b.String()
193}
194
195// ── Validation helpers (pure, no side-effects) ────────────────
196
197func validRating(r int) bool { return r >= 1 && r <= 5 }
198
199func validBody(b string) bool { return len(b) <= MaxBodyLen }
200
201// pairKey returns the authorSubject tree key for a (subject, author) pair.
202// The NUL separator prevents prefix collisions between subject and author.
203func pairKey(subject string, a address) string { return subject + "\x00" + a.String() }
204
205// ── Write functions ───────────────────────────────────────────
206
207// PostReview creates the caller's review for `subject`, or replaces it in place
208// if one already exists (the "one editable review per pair" rule).
209func PostReview(cur realm, subject string, rating int, body string) {
210 caller := unsafe.PreviousRealm().Address()
211 if subject == "" {
212 panic("subject required")
213 }
214 if !validRating(rating) {
215 panic("rating must be 1..5")
216 }
217 if !validBody(body) {
218 panic("body too long")
219 }
220
221 pk := pairKey(subject, caller)
222 if v, ok := authorSubject.Get(pk); ok {
223 r, found := getReview(v.(uint64))
224 if found && !r.Deleted && !r.Hidden {
225 r.Rating = rating
226 r.Body = body
227 r.EditedAt = runtime.ChainHeight()
228 reviews.Set(strID(r.ID), r)
229 chain.Emit("ReviewUpdated", "id", strID(r.ID), "subject", subject)
230 return
231 }
232 }
233
234 id := nextID
235 nextID++
236 r := &Review{
237 ID: id,
238 Subject: subject,
239 Author: caller,
240 Rating: rating,
241 Body: body,
242 CreatedAt: runtime.ChainHeight(),
243 }
244 reviews.Set(strID(id), r)
245 authorSubject.Set(pk, id)
246 subjectIndex.Set(subject, append(idList(subjectIndex, subject), id))
247 chain.Emit("ReviewPosted", "id", strID(id), "subject", subject, "author", caller.String())
248}
249
250// EditReview updates rating + body of an existing non-deleted review.
251// Only the original author may edit.
252func EditReview(cur realm, reviewID uint64, rating int, body string) {
253 caller := unsafe.PreviousRealm().Address()
254 r, ok := getReview(reviewID)
255 if !ok || r.Deleted {
256 panic("review not found")
257 }
258 if r.Author != caller {
259 panic("author only")
260 }
261 if !validRating(rating) {
262 panic("rating must be 1..5")
263 }
264 if !validBody(body) {
265 panic("body too long")
266 }
267 r.Rating = rating
268 r.Body = body
269 r.EditedAt = runtime.ChainHeight()
270 reviews.Set(strID(reviewID), r)
271 chain.Emit("ReviewUpdated", "id", strID(reviewID), "subject", r.Subject)
272}
273
274// DeleteReview tombstones the caller's review: keeps the ID + reaction history,
275// clears the body, and frees the (author, subject) pair for a fresh review.
276// Only the original author may delete.
277func DeleteReview(cur realm, reviewID uint64) {
278 caller := unsafe.PreviousRealm().Address()
279 r, ok := getReview(reviewID)
280 if !ok || r.Deleted {
281 panic("review not found")
282 }
283 if r.Author != caller {
284 panic("author only")
285 }
286 r.Deleted = true
287 r.Body = ""
288 reviews.Set(strID(reviewID), r)
289 authorSubject.Remove(pairKey(r.Subject, caller))
290 subjectIndex.Set(r.Subject, removeID(idList(subjectIndex, r.Subject), reviewID))
291 chain.Emit("ReviewDeleted", "id", strID(reviewID), "subject", r.Subject)
292}
293
294// ── Comment validation (pure, no side-effects) ────────────────
295
296// validComment returns true iff body is non-empty and within MaxCommentLen.
297func validComment(b string) bool { return b != "" && len(b) <= MaxCommentLen }
298
299// ── Comment write functions ───────────────────────────────────
300
301// PostComment posts a flat reply to an existing, non-deleted, non-hidden review.
302func PostComment(cur realm, reviewID uint64, body string) {
303 caller := unsafe.PreviousRealm().Address()
304 r, ok := getReview(reviewID)
305 if !ok || r.Deleted || r.Hidden {
306 panic("review not found")
307 }
308 if !validComment(body) {
309 panic("comment length invalid")
310 }
311 id := nextID
312 nextID++
313 c := &Comment{
314 ID: id,
315 ReviewID: reviewID,
316 Author: caller,
317 Body: body,
318 CreatedAt: runtime.ChainHeight(),
319 }
320 comments.Set(strID(id), c)
321 commentIndex.Set(strID(reviewID), append(idList(commentIndex, strID(reviewID)), id))
322 chain.Emit("CommentPosted", "id", strID(id), "review", strID(reviewID), "author", caller.String())
323}
324
325// EditComment updates the body of an existing, non-deleted comment.
326// Only the original author may edit.
327func EditComment(cur realm, commentID uint64, body string) {
328 caller := unsafe.PreviousRealm().Address()
329 c, ok := getComment(commentID)
330 if !ok || c.Deleted {
331 panic("comment not found")
332 }
333 if c.Author != caller {
334 panic("author only")
335 }
336 if !validComment(body) {
337 panic("comment length invalid")
338 }
339 c.Body = body
340 c.EditedAt = runtime.ChainHeight()
341 comments.Set(strID(commentID), c)
342 chain.Emit("CommentUpdated", "id", strID(commentID))
343}
344
345// DeleteComment tombstones the caller's comment: clears the body.
346// Only the original author may delete.
347func DeleteComment(cur realm, commentID uint64) {
348 caller := unsafe.PreviousRealm().Address()
349 c, ok := getComment(commentID)
350 if !ok || c.Deleted {
351 panic("comment not found")
352 }
353 if c.Author != caller {
354 panic("author only")
355 }
356 c.Deleted = true
357 c.Body = ""
358 comments.Set(strID(commentID), c)
359 chain.Emit("CommentDeleted", "id", strID(commentID))
360}
361
362// ── Reaction helpers (pure, no side-effects) ──────────────────
363
364func boolToInt(b bool) int {
365 if b {
366 return 1
367 }
368 return 0
369}
370
371// reactionDelta returns how a reaction change (old -> newKind, each "" | "like" | "dislike")
372// moves the target's like count, dislike count, and the target author's reputation.
373// repDelta = likesDelta - dislikesDelta.
374func reactionDelta(old, newKind string) (likesDelta, dislikesDelta, repDelta int) {
375 likesDelta = boolToInt(newKind == "like") - boolToInt(old == "like")
376 dislikesDelta = boolToInt(newKind == "dislike") - boolToInt(old == "dislike")
377 repDelta = likesDelta - dislikesDelta
378 return
379}
380
381// applyDelta adjusts an unsigned counter by ±1-style delta without underflow.
382func applyDelta(v uint64, delta int) uint64 {
383 if delta < 0 {
384 d := uint64(-delta)
385 if v < d {
386 return 0
387 }
388 return v - d
389 }
390 return v + uint64(delta)
391}
392
393// ── React ─────────────────────────────────────────────────────
394
395// React records or toggles a like/dislike on a review or comment.
396// Re-reacting with the same kind toggles it off; switching kind replaces it.
397// Updates the target's Likes/Dislikes counters and the author's reputation by
398// Δ(likes−dislikes). Self-reactions are rejected. Deleted/hidden targets are rejected.
399func React(cur realm, targetID uint64, kind string) {
400 caller := unsafe.PreviousRealm().Address()
401 if kind != "like" && kind != "dislike" {
402 panic("kind must be like or dislike")
403 }
404
405 r, isReview := getReview(targetID)
406 c, isComment := getComment(targetID)
407 if !isReview && !isComment {
408 panic("target not found")
409 }
410
411 var author address
412 if isReview {
413 author = r.Author
414 if r.Deleted || r.Hidden {
415 panic("target not found")
416 }
417 } else {
418 author = c.Author
419 if c.Deleted || c.Hidden {
420 panic("target not found")
421 }
422 }
423 if author == caller {
424 panic("cannot react to your own review or comment")
425 }
426
427 rk := strID(targetID) + "/" + caller.String()
428 var old string
429 if v, ok := reactions.Get(rk); ok {
430 old = v.(string)
431 }
432
433 newKind := kind
434 if old == kind {
435 newKind = "" // toggle off
436 }
437
438 likesDelta, dislikesDelta, repDelta := reactionDelta(old, newKind)
439
440 if newKind == "" {
441 reactions.Remove(rk)
442 } else {
443 reactions.Set(rk, newKind)
444 }
445
446 if isReview {
447 r.Likes = applyDelta(r.Likes, likesDelta)
448 r.Dislikes = applyDelta(r.Dislikes, dislikesDelta)
449 reviews.Set(strID(targetID), r)
450 } else {
451 c.Likes = applyDelta(c.Likes, likesDelta)
452 c.Dislikes = applyDelta(c.Dislikes, dislikesDelta)
453 comments.Set(strID(targetID), c)
454 }
455 addReputation(author.String(), int64(repDelta))
456 chain.Emit("Reacted", "target", strID(targetID), "kind", newKind, "by", caller.String())
457}
458
459// ── Flag + moderation ─────────────────────────────────────────
460
461// Flag records one community flag per account per target (review or comment).
462// Auto-hide is NOT performed here; takedowns are multisig-only (HideReview/HideComment).
463// Deleted/hidden targets are rejected.
464func Flag(cur realm, targetID uint64) {
465 caller := unsafe.PreviousRealm().Address()
466 r, isReview := getReview(targetID)
467 c, isComment := getComment(targetID)
468 if !isReview && !isComment {
469 panic("target not found")
470 }
471 if isReview && (r.Deleted || r.Hidden) {
472 panic("target not found")
473 }
474 if isComment && (c.Deleted || c.Hidden) {
475 panic("target not found")
476 }
477 fk := strID(targetID) + "/" + caller.String()
478 if _, ok := flags.Get(fk); ok {
479 panic("already flagged")
480 }
481 flags.Set(fk, true)
482
483 if isReview {
484 r.FlagCount++
485 reviews.Set(strID(targetID), r)
486 } else {
487 c.FlagCount++
488 comments.Set(strID(targetID), c)
489 }
490 flaggedIDs.Set(strID(targetID), true)
491 chain.Emit("Flagged", "target", strID(targetID), "by", caller.String())
492}
493
494// HideReview soft-deletes a review. Moderator (multisig) only.
495func HideReview(cur realm, id uint64) {
496 assertModerator()
497 r, ok := getReview(id)
498 if !ok {
499 panic("review not found")
500 }
501 r.Hidden = true
502 reviews.Set(strID(id), r)
503 chain.Emit("Hidden", "target", strID(id))
504}
505
506// HideComment soft-deletes a comment. Moderator (multisig) only.
507func HideComment(cur realm, id uint64) {
508 assertModerator()
509 c, ok := getComment(id)
510 if !ok {
511 panic("comment not found")
512 }
513 c.Hidden = true
514 comments.Set(strID(id), c)
515 chain.Emit("Hidden", "target", strID(id))
516}
517
518// Unhide reverses a hide (manual or auto-flag) on a review or comment.
519// Moderator (multisig) only. Also removes the target from the mod dashboard index.
520func Unhide(cur realm, targetID uint64) {
521 assertModerator()
522 if r, ok := getReview(targetID); ok {
523 r.Hidden = false
524 reviews.Set(strID(targetID), r)
525 flaggedIDs.Remove(strID(targetID))
526 chain.Emit("Unhidden", "target", strID(targetID))
527 return
528 }
529 if c, ok := getComment(targetID); ok {
530 c.Hidden = false
531 comments.Set(strID(targetID), c)
532 flaggedIDs.Remove(strID(targetID))
533 chain.Emit("Unhidden", "target", strID(targetID))
534 return
535 }
536 panic("target not found")
537}
538
539// ── Read helpers ──────────────────────────────────────────────
540
541// clampLimit ensures limit is in [1, MaxPageLimit]. Zero or negative values and
542// values above MaxPageLimit are all clamped to MaxPageLimit.
543func clampLimit(limit int) int {
544 if limit <= 0 || limit > MaxPageLimit {
545 return MaxPageLimit
546 }
547 return limit
548}
549
550// window slices ids[offset : offset+limit] safely (no panics on out-of-range).
551func window(ids []uint64, offset, limit int) []uint64 {
552 limit = clampLimit(limit)
553 if offset < 0 {
554 offset = 0
555 }
556 if offset >= len(ids) {
557 return nil
558 }
559 end := offset + limit
560 if end > len(ids) {
561 end = len(ids)
562 }
563 return ids[offset:end]
564}
565
566func reviewJSON(r *Review) string {
567 body := ""
568 if !r.Deleted {
569 body = jsonEscape(r.Body)
570 }
571 return ufmt.Sprintf(
572 `{"id":%d,"subject":"%s","author":"%s","rating":%d,"body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
573 r.ID, jsonEscape(r.Subject), jsonEscape(r.Author.String()), r.Rating, body,
574 r.CreatedAt, r.EditedAt, r.Deleted, r.Likes, r.Dislikes, r.FlagCount,
575 getReputation(r.Author.String()),
576 )
577}
578
579func commentJSON(c *Comment) string {
580 body := ""
581 if !c.Deleted {
582 body = jsonEscape(c.Body)
583 }
584 return ufmt.Sprintf(
585 `{"id":%d,"reviewId":%d,"author":"%s","body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
586 c.ID, c.ReviewID, jsonEscape(c.Author.String()), body,
587 c.CreatedAt, c.EditedAt, c.Deleted, c.Likes, c.Dislikes, c.FlagCount,
588 getReputation(c.Author.String()),
589 )
590}
591
592// GetReviewsJSON returns a JSON array of a subject's non-hidden reviews (paginated).
593// Deleted reviews are included as tombstones (empty body, deleted:true).
594func GetReviewsJSON(subject string, offset, limit int) string {
595 ids := window(idList(subjectIndex, subject), offset, limit)
596 var b strings.Builder
597 b.WriteString("[")
598 first := true
599 for _, id := range ids {
600 r, ok := getReview(id)
601 if !ok || r.Hidden {
602 continue
603 }
604 if !first {
605 b.WriteString(",")
606 }
607 b.WriteString(reviewJSON(r))
608 first = false
609 }
610 b.WriteString("]")
611 return b.String()
612}
613
614// GetCommentsJSON returns a JSON array of a review's non-hidden comments (paginated).
615func GetCommentsJSON(reviewID uint64, offset, limit int) string {
616 ids := window(idList(commentIndex, strID(reviewID)), offset, limit)
617 var b strings.Builder
618 b.WriteString("[")
619 first := true
620 for _, id := range ids {
621 c, ok := getComment(id)
622 if !ok || c.Hidden {
623 continue
624 }
625 if !first {
626 b.WriteString(",")
627 }
628 b.WriteString(commentJSON(c))
629 first = false
630 }
631 b.WriteString("]")
632 return b.String()
633}
634
635// GetSubjectSummaryJSON returns {"count":N,"average":A,"sum":S} over non-hidden,
636// non-deleted reviews for a subject. Average is integer-rounded.
637func GetSubjectSummaryJSON(subject string) string {
638 ids := idList(subjectIndex, subject)
639 var sum, n int64
640 for _, id := range ids {
641 r, ok := getReview(id)
642 if !ok || r.Hidden || r.Deleted {
643 continue
644 }
645 sum += int64(r.Rating)
646 n++
647 }
648 avg := int64(0)
649 if n > 0 {
650 avg = (sum + n/2) / n // round to nearest
651 }
652 return ufmt.Sprintf(`{"count":%d,"average":%d,"sum":%d}`, n, avg, sum)
653}
654
655// GetReputation returns the net likes−dislikes reputation for an address.
656// Returns 0 for unknown addresses. Queried via vm/qeval (bare int64).
657func GetReputation(addr string) int64 { return getReputation(addr) }
658
659// GetFlaggedJSON returns a JSON array of flagged target IDs for the mod dashboard,
660// paginated. IDs are returned as bare integers (not quoted strings).
661// Uses early-stop iteration to avoid loading the entire tree.
662func GetFlaggedJSON(offset, limit int) string {
663 limit = clampLimit(limit)
664 if offset < 0 {
665 offset = 0
666 }
667 var ids []uint64
668 skipped := 0
669 flaggedIDs.Iterate("", "", func(key string, _ interface{}) bool {
670 if skipped < offset {
671 skipped++
672 return false
673 }
674 id, _ := strconv.ParseUint(key, 10, 64)
675 ids = append(ids, id)
676 return len(ids) >= limit // stop once collected enough
677 })
678 var b strings.Builder
679 b.WriteString("[")
680 for i, id := range ids {
681 if i > 0 {
682 b.WriteString(",")
683 }
684 b.WriteString(strID(id))
685 }
686 b.WriteString("]")
687 return b.String()
688}
689
690// Render — human gnoweb view.
691// "" → home (total count); "s/<subject>" → subject review list; else 404.
692func Render(path string) string {
693 if path == "" {
694 return ufmt.Sprintf("# Memba Reviews\n\nOn-chain web-of-trust. %d reviews total.\n", reviews.Size())
695 }
696 if strings.HasPrefix(path, "s/") {
697 subject := strings.TrimPrefix(path, "s/")
698 var b strings.Builder
699 b.WriteString("# Reviews for " + sanitizeForRender(subject) + "\n\n")
700 written := false
701 renderedCount := 0
702 for _, id := range idList(subjectIndex, subject) {
703 if renderedCount >= MaxPageLimit {
704 break
705 }
706 r, ok := getReview(id)
707 if !ok || r.Hidden || r.Deleted {
708 continue
709 }
710 b.WriteString(ufmt.Sprintf("**%d/5** by %s\n\n%s\n\n---\n",
711 r.Rating, r.Author.String(), sanitizeForRender(r.Body)))
712 written = true
713 renderedCount++
714 }
715 if !written {
716 b.WriteString("No reviews yet.\n")
717 }
718 return b.String()
719 }
720 return "# 404\n"
721}