guestbook.gno
3.95 Kb · 146 lines
1// Package guestbook is a public, on-chain guestbook realm for gno.land.
2//
3// Anyone can sign the guestbook with a short message via the Sign crossing
4// function. Every entry records the caller's address, the message, and the
5// block height at which it was signed. Render lists all entries newest-first
6// as a Markdown table.
7package guestbook
8
9import (
10 "strconv"
11 "strings"
12
13 "chain"
14 "chain/runtime"
15
16 "gno.land/p/nt/avl/v0"
17)
18
19// Entry is a single signed guestbook line.
20type Entry struct {
21 ID int // 1-based sequence number
22 Author string // bech32 address of the signer
23 Message string // the message left by the signer
24 Height int64 // block height at which the entry was signed
25}
26
27const maxMessageLen = 280
28
29var (
30 // entries is keyed by a zero-padded sequence so avl iteration is ordered.
31 entries = avl.NewTree()
32 count int
33)
34
35// Sign appends a new entry to the guestbook attributed to the immediate caller.
36//
37// It is a crossing function (gno 0.9 interrealm convention): callers invoke it
38// as Sign(cross(cur), "hello"). The cur.IsCurrent() guard is the authentication
39// primitive — without it a stale/forged realm value could spoof the author.
40func Sign(cur realm, message string) {
41 if !cur.IsCurrent() {
42 panic("guestbook: spoofed realm; Sign must be called via cross(cur)")
43 }
44
45 author := cur.Previous().Address().String()
46 e := addEntry(author, message, runtime.ChainHeight())
47
48 chain.Emit("Signed",
49 "id", strconv.Itoa(e.ID),
50 "author", e.Author,
51 "height", strconv.FormatInt(e.Height, 10),
52 )
53}
54
55// addEntry validates the message and appends an entry. Non-crossing internal
56// helper so the append/validation logic is unit-testable without a realm frame.
57// Panics (aborting the tx, reverting state) on an invalid message.
58func addEntry(author, message string, height int64) *Entry {
59 msg := strings.TrimSpace(message)
60 if msg == "" {
61 panic("guestbook: message must not be empty")
62 }
63 if len(msg) > maxMessageLen {
64 panic("guestbook: message too long (max " + strconv.Itoa(maxMessageLen) + " bytes)")
65 }
66
67 count++
68 e := &Entry{
69 ID: count,
70 Author: author,
71 Message: msg,
72 Height: height,
73 }
74 entries.Set(seqKey(count), e)
75 return e
76}
77
78// Count returns the total number of signatures. Read-only, non-crossing.
79func Count() int {
80 return count
81}
82
83// Render lists every entry newest-first as a Markdown table plus a total count.
84func Render(path string) string {
85 var b strings.Builder
86
87 b.WriteString("# Guestbook\n\n")
88
89 if count == 0 {
90 b.WriteString("_No one has signed yet. Be the first!_\n")
91 return b.String()
92 }
93
94 b.WriteString("**Total signatures:** ")
95 b.WriteString(strconv.Itoa(count))
96 b.WriteString("\n\n")
97
98 b.WriteString("| # | Who | Message | Height |\n")
99 b.WriteString("|---|-----|---------|--------|\n")
100
101 // avl iterates ascending by key; ReverseIterate gives newest-first.
102 entries.ReverseIterate("", "", func(_ string, v any) bool {
103 e := v.(*Entry)
104 b.WriteString("| ")
105 b.WriteString(strconv.Itoa(e.ID))
106 b.WriteString(" | ")
107 b.WriteString(shortAddr(e.Author))
108 b.WriteString(" | ")
109 b.WriteString(escapeCell(e.Message))
110 b.WriteString(" | ")
111 b.WriteString(strconv.FormatInt(e.Height, 10))
112 b.WriteString(" |\n")
113 return false
114 })
115
116 return b.String()
117}
118
119// seqKey renders a sequence number as a fixed-width, zero-padded string so
120// avl's lexicographic key order matches numeric order.
121func seqKey(n int) string {
122 s := strconv.Itoa(n)
123 const width = 16
124 if len(s) >= width {
125 return s
126 }
127 return strings.Repeat("0", width-len(s)) + s
128}
129
130// shortAddr abbreviates a bech32 address for compact display.
131func shortAddr(addr string) string {
132 if len(addr) <= 12 {
133 return addr
134 }
135 return addr[:8] + "…" + addr[len(addr)-4:]
136}
137
138// escapeCell neutralizes characters that would break a Markdown table cell.
139func escapeCell(s string) string {
140 s = strings.ReplaceAll(s, "\\", "\\\\")
141 s = strings.ReplaceAll(s, "|", "\\|")
142 s = strings.ReplaceAll(s, "\r\n", " ")
143 s = strings.ReplaceAll(s, "\n", " ")
144 s = strings.ReplaceAll(s, "\r", " ")
145 return s
146}