quotes.gno
5.01 Kb · 181 lines
1// Package quotes is a community "Quote of the Moment" board realm.
2//
3// Anyone can submit a quote via the exported crossing function AddQuote.
4// Each quote records its text, the address of the caller who added it, and
5// the block height at submission time. Render deterministically picks a
6// "featured" quote by the current block height (a stable index into the
7// list), shows it as a blockquote, then lists every quote with its author
8// and id below.
9package quotes
10
11import (
12 "strconv"
13 "strings"
14
15 "chain"
16 "chain/runtime"
17)
18
19// quote is a single board entry. Author is stored as a plain string address
20// (realm values are ephemeral and must never be persisted).
21type quote struct {
22 id int
23 text string
24 author string // bech32 address of the submitter, or "" for seeded quotes
25 height int64 // block height at submission (0 for seeded quotes)
26}
27
28// board is the persistent realm state. A plain slice is fine here: the list
29// only ever grows by append and Render walks it in insertion order, which is
30// deterministic.
31var board []quote
32
33// init seeds the board so a fresh deploy renders nicely.
34func init() {
35 seed := []string{
36 "The unexamined life is not worth living.",
37 "We suffer more often in imagination than in reality.",
38 "Waste no more time arguing about what a good person should be. Be one.",
39 "He who is brave is free.",
40 }
41 for _, s := range seed {
42 board = append(board, quote{
43 id: len(board),
44 text: s,
45 author: "", // seeded
46 height: 0,
47 })
48 }
49}
50
51// AddQuote is the exported, state-mutating entry point. It follows the gno 0.9
52// interrealm crossing convention: the first parameter is `cur realm`. Callers
53// invoke it as AddQuote(cross(cur), "...") over a MsgCall.
54func AddQuote(cur realm, text string) {
55 // Authentication primitive: reject a stale/forged realm value before
56 // deriving any caller identity from it.
57 if !cur.IsCurrent() {
58 panic("spoofed realm: cur is not the live crossing frame")
59 }
60
61 if err := validate(text); err != nil {
62 panic(err)
63 }
64
65 author := cur.Previous().Address().String()
66 h := runtime.ChainHeight()
67
68 board = append(board, quote{
69 id: len(board),
70 text: text,
71 author: author,
72 height: h,
73 })
74
75 chain.Emit("QuoteAdded",
76 "id", strconv.Itoa(len(board)-1),
77 "author", author,
78 "height", strconv.FormatInt(h, 10),
79 )
80}
81
82// maxQuoteLen caps quote length to keep storage costs bounded and Render tidy.
83const maxQuoteLen = 280
84
85// validate enforces basic content rules. Returns an error describing the
86// problem, or nil when the text is acceptable.
87func validate(text string) error {
88 if len(text) == 0 {
89 return errEmpty
90 }
91 if len(text) > maxQuoteLen {
92 return errTooLong
93 }
94 return nil
95}
96
97// Sentinel errors for validation. Kept as package-level vars so tests can
98// compare against them directly.
99var (
100 errEmpty = quoteError("quote text must not be empty")
101 errTooLong = quoteError("quote text exceeds " + strconv.Itoa(maxQuoteLen) + " characters")
102)
103
104// quoteError is a trivial string-backed error type (the VM has no host
105// stdlib errors package convenience beyond this).
106type quoteError string
107
108func (e quoteError) Error() string { return string(e) }
109
110// featuredIndex deterministically picks which quote is "of the moment" for a
111// given block height. Pure function of (height, count) so it is fully
112// deterministic and easy to test.
113func featuredIndex(height int64, count int) int {
114 if count <= 0 {
115 return -1
116 }
117 // Guard against a negative height (should not happen on-chain, but keeps
118 // the modulo well-defined).
119 if height < 0 {
120 height = -height
121 }
122 return int(height % int64(count))
123}
124
125// Render produces the Markdown page shown in gnoweb. It is NOT a crossing
126// function (no `cur realm` parameter) — it is a read-only view.
127func Render(path string) string {
128 count := len(board)
129 if count == 0 {
130 return "# Quote of the Moment\n\n_No quotes yet. Be the first to add one._\n"
131 }
132
133 h := runtime.ChainHeight()
134 idx := featuredIndex(h, count)
135 featured := board[idx]
136
137 var b strings.Builder
138 b.WriteString("# Quote of the Moment\n\n")
139
140 // Featured quote as a blockquote.
141 b.WriteString("> ")
142 b.WriteString(featured.text)
143 b.WriteString("\n>\n> — ")
144 b.WriteString(authorLabel(featured))
145 b.WriteString(" (#")
146 b.WriteString(strconv.Itoa(featured.id))
147 b.WriteString(")\n\n")
148
149 b.WriteString("_Featured pick rotates with block height ")
150 b.WriteString(strconv.FormatInt(h, 10))
151 b.WriteString(" — index ")
152 b.WriteString(strconv.Itoa(idx))
153 b.WriteString(" of ")
154 b.WriteString(strconv.Itoa(count))
155 b.WriteString("._\n\n")
156
157 // Full list.
158 b.WriteString("## All quotes (")
159 b.WriteString(strconv.Itoa(count))
160 b.WriteString(")\n\n")
161 for _, q := range board {
162 b.WriteString("- **#")
163 b.WriteString(strconv.Itoa(q.id))
164 b.WriteString("** ")
165 b.WriteString(q.text)
166 b.WriteString(" — _")
167 b.WriteString(authorLabel(q))
168 b.WriteString("_\n")
169 }
170
171 return b.String()
172}
173
174// authorLabel renders a human-friendly author string. Seeded quotes (empty
175// author) show as the house account.
176func authorLabel(q quote) string {
177 if q.author == "" {
178 return "the board"
179 }
180 return q.author
181}