chat.gno
16.40 Kb · 401 lines
1// Package chat is the SPLASH arcade's room: a bounded, append-only ring of
2// public messages, posted by anyone with a wallet and readable by anyone at
3// all.
4//
5// IT EXISTS BECAUSE SETTLEMENT IS A SHARED MOMENT. Duels pin a future drand
6// round and many of them land on the SAME round — the bucketing in
7// arcade/duel is deliberate about that. A dozen people watching one number
8// arrive is the thing worth being in a room for. Without somewhere to say so
9// it is a dozen people each alone with a spinner.
10//
11// WHAT THIS REALM DELIBERATELY DOES NOT HAVE, and why each absence is a
12// decision rather than an omission:
13//
14// - NO ADMIN, NO OWNER, NO DELETE, NO EDIT. Not "not yet" — there is no
15// address anywhere in this file with a power no other address has. The
16// rules page promises "no admin over your money" and "nobody can pause,
17// delay or refuse"; a moderator key over other people's words is the same
18// promise broken in a different column. Ruled by the owner 2026-10-08
19// after being shown the trade explicitly: the alternative was a key that
20// could remove messages, and he chose not to have one.
21//
22// - NO MONEY. This realm holds no coin, has no allowance, makes no
23// transfer and imports no token. It cannot lose funds because it never
24// has any. That is most of its security argument and it is why the whole
25// file is this short.
26//
27// THAT INVARIANT NEEDS AN ACTIVE GUARD, NOT JUST THE ABSENCE OF CODE, and
28// the first draft of this file got that wrong. A realm receives native
29// coin whether or not it has any code to do so: `maketx call --send
30// 1000000ugnot -func Post` credits this realm's address at the chain
31// root, and Post would have succeeded and kept it. With no banker, no
32// owner and no withdraw — all deliberate, all permanent, because a realm
33// cannot be upgraded — that coin would be destroyed rather than merely
34// stuck. Hence rejectAttachedCoin below, which is the same guard the
35// other four SPLASH realms have carried since 2026-10-07. Caught by audit
36// before deployment, 2026-10-08.
37//
38// THE GUARD COVERS Post AND NOTHING ELSE, and cannot be made to cover
39// more. Gno gives a realm no receive hook, so a plain `maketx send` to
40// this address, a wugnot transfer, or any other realm's banker can put
41// value here and no code anywhere could refuse it. The eight read
42// functions are non-crossing and so have no `cur` to check at all. What
43// the guard buys is the one case a normal user could hit by accident —
44// attaching coin to the call they actually meant to make. Everything
45// else burns the sender's own coin and harms nobody: this realm has no
46// accounting, reads no balance and pays nothing out, so stray value
47// cannot change any behaviour. Said plainly here so the next reader does
48// not mistake "guarded" for "impossible".
49//
50// - NO REPLIES, NO THREADS, NO MENTIONS, NO REACTIONS. A flat room. Every
51// one of those is a second index, and a second index is a second thing
52// that can grow without bound.
53//
54// WHAT IT DOES HAVE, and the reasoning that is NOT obvious from the code:
55//
56// - A FIXED-SIZE RING. Storage on a chain is paid for once and kept
57// forever. An unbounded log would make every future Post more expensive
58// than the last and eventually price the room shut. The ring overwrites;
59// Seen() reports the true total so nobody mistakes "the ring holds 64"
60// for "64 messages have ever been sent". The event log is the durable
61// record — see the chain.Emit in Post.
62//
63// - A PER-ADDRESS COOLDOWN. THIS IS NOT MODERATION and the distinction
64// matters enough to state: it is a rule of arithmetic applied identically
65// to every address including ours, enforced by the contract, with no
66// human in the loop and no exceptions possible. It stops ONE WALLET
67// bursting and evicting the room in a single block, and that is all it
68// stops — 64 funded addresses still wipe the ring in a block or two, and
69// that is accepted. The exact arithmetic lives on cooldownBlocks and is
70// deliberately NOT repeated here: an earlier version stated it in both
71// places, the correction landed on one, and the false figure sat at the
72// top of the file where most readers meet it first. One claim, one home.
73// It cannot silence anybody — it only makes everyone wait the same
74// amount.
75package chat
76
77import (
78 "strconv"
79 "time"
80 "unicode/utf8"
81
82 "chain"
83 "chain/runtime"
84 "chain/runtime/unsafe"
85
86 "gno.land/p/nt/avl/v0"
87 "gno.land/p/nt/markdown/sanitize/v0"
88)
89
90const (
91 // maxTextLen is the cap on one message, in BYTES not runes — because
92 // bytes are what storage costs and what len() measures. 280 bytes of
93 // UTF-8 is roughly 280 Latin characters or ~93 emoji; the room is for
94 // "GET IN", not for essays.
95 maxTextLen = 280
96
97 // ringSize is how many messages are readable at once. 64 is chosen
98 // against the thing this room is FOR: a settlement bucket is 100 drand
99 // rounds, i.e. five minutes, and a busy five minutes is tens of
100 // messages, not hundreds. Large enough to hold a whole settlement,
101 // small enough that the realm's storage never becomes a reason not to
102 // post.
103 ringSize = 64
104
105 // cooldownBlocks is the minimum gap between two messages from the SAME
106 // address. onyx blocks are ~3.3 seconds, so 2 blocks is ~7 seconds.
107 //
108 // IT IS DELIBERATELY SMALL. The job is to stop one wallet evicting the
109 // ring in a single burst, NOT to pace conversation — a room where
110 // replying feels slow is a room nobody uses, and the whole point is the
111 // shared moment.
112 //
113 // WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first
114 // draft of this comment got the arithmetic wrong and would have misled
115 // whoever next decided whether more was needed. It claimed flooding the
116 // ring would cost cooldownBlocks x ringSize = 128 blocks, "hours, in
117 // public". That is false. The cooldown is per address and is skipped
118 // entirely for an address with no entry, so SIXTY-FOUR funded addresses
119 // posting once each evict the whole room INSIDE ONE BLOCK. The real cost
120 // of a wipe is 64 funded addresses, not 128 blocks.
121 //
122 // So: this stops a SINGLE WALLET bursting, and that is all it stops.
123 // Buying the property the old comment described would need a global rate
124 // limit, which is a different mechanism and a product decision nobody has
125 // made. Sybil flooding by many addresses is accepted for a testnet room
126 // with no money in it. Corrected after audit, 2026-10-08.
127 cooldownBlocks = 2
128)
129
130// Message is one line in the room. Every field is set by the contract from
131// the transaction itself EXCEPT Text, which is the only caller-supplied value
132// in this realm — so Text is the only thing any reader has to treat as
133// hostile. Author cannot be forged: it is read from the call, never from an
134// argument.
135type Message struct {
136 ID int64 // monotonic, never reused, survives ring eviction
137 Author address // the EOA that signed; set by the contract, not the caller
138 Text string // the ONLY caller-controlled field in this struct
139 Height int64 // block it landed in
140 Time int64 // block timestamp, unix seconds
141}
142
143var (
144 messages []Message // len is capped at ringSize
145 next int // write cursor; wraps once full
146 seen int64 // total ever posted, including those overwritten
147
148 // lastPost maps address -> block height of that address's last message,
149 // and is the cooldown's whole state.
150 //
151 // THIS IS THE ONE UNBOUNDED STRUCTURE IN THE REALM, and that is a
152 // considered trade rather than an oversight. It grows by one entry per
153 // DISTINCT address that has ever posted, which is bounded by the number
154 // of real participants, and each entry is an address and an int. It
155 // cannot be made to grow faster than the cooldown allows: an attacker
156 // buying entries with fresh addresses pays a transaction per entry and
157 // gains nothing but a row. The alternative — storing the cooldown in the
158 // ring and scanning it — would silently stop working the moment a
159 // flooder's own messages rolled off the end, which is precisely when the
160 // cooldown is needed.
161 lastPost = avl.NewTree()
162)
163
164// --- Writing ---------------------------------------------------------------
165
166// Post appends one message and returns its ID.
167//
168// It is a crossing function because it reads the caller's identity, and it
169// requires an EOA: cur.Previous().IsUserCall(). A realm posting on a user's
170// behalf would make Author meaningless — the room would show the realm's
171// address for every line and there would be no way to tell who said what.
172// There is no reason for a contract to speak here.
173func Post(cur realm, text string) int64 {
174 // FIRST, before the authorisation check — same ordering as ops.gno, so
175 // that a caller who both attached coin and failed some other check is
176 // told about the coin, which is the one that costs them money.
177 rejectAttachedCoin(cur)
178 if !cur.Previous().IsUserCall() {
179 panic("chat: only a wallet can post, not a contract")
180 }
181 author := cur.Previous().Address()
182 checkText(text)
183
184 h := runtime.ChainHeight()
185 key := string(author)
186 if lastPost.Has(key) {
187 prev := lastPost.Get(key).(int64)
188 // Strictly-less comparison on the SUM, not a subtraction — these are
189 // int64 block heights and cannot overflow in any reachable chain
190 // lifetime, but the sum form says the intent without the reader
191 // having to check for a negative difference.
192 if h < prev+cooldownBlocks {
193 panic("chat: too soon — wait " + strconv.FormatInt(prev+cooldownBlocks-h, 10) + " more block(s)")
194 }
195 }
196 lastPost.Set(key, h)
197
198 seen++
199 m := Message{
200 ID: seen,
201 Author: author,
202 Text: text,
203 Height: h,
204 // time.Now() IN A REALM IS BLOCK TIME, not wall clock — whole
205 // seconds, identical for every call in the same block. It is the
206 // same clock beacon/v2 uses to work out the current drand round,
207 // which is what makes a message's timestamp comparable with the
208 // round a duel is waiting on.
209 Time: time.Now().Unix(),
210 }
211 if len(messages) < ringSize {
212 messages = append(messages, m)
213 next = len(messages) % ringSize
214 } else {
215 messages[next] = m
216 next = (next + 1) % ringSize
217 }
218
219 // THE EVENT LOG IS THE DURABLE RECORD. The ring forgets; this does not,
220 // and it is how a front end backfills past the 64 the realm will hand
221 // it.
222 //
223 // THE TEXT HAS TO BE IN HERE. The first draft emitted id, author and
224 // height only, which made the paragraph above false: a front end
225 // following it would have got a list of ids with no messages. (The words
226 // were not destroyed — they live in the transaction's calldata forever —
227 // but recovering them by scanning transactions is a different and much
228 // worse mechanism than the one this comment promises.) Caught by audit
229 // before deployment, 2026-10-08.
230 chain.Emit("Said",
231 "id", strconv.FormatInt(m.ID, 10),
232 "author", string(author),
233 "height", strconv.FormatInt(h, 10),
234 "text", m.Text,
235 )
236 return m.ID
237}
238
239// rejectAttachedCoin panics if the caller attached native coin. This realm has
240// no use for any, and — unlike every other realm in SPLASH — no way whatsoever
241// to give it back. See the note in the package comment.
242//
243// Inert on a non-user call, for the reason r/splash/exchange's Buy comment
244// gives: unsafe.OriginSend() reads the envelope attached at the chain root,
245// shared by every realm in the call chain, so reading it is only sound when
246// the immediate caller is an EOA. Post refuses non-EOA callers on the very
247// next line anyway, so nothing is let through by this early return.
248func rejectAttachedCoin(cur realm) {
249 if !cur.Previous().IsUserCall() {
250 return
251 }
252 if sent := unsafe.OriginSend(); len(sent) != 0 {
253 panic("chat: posting does not take native coin — this realm cannot hold or return it")
254 }
255}
256
257// checkText is the whole input policy, in one place so there is one answer to
258// "what is allowed".
259//
260// Control characters are rejected rather than stripped, deliberately: a
261// silent strip means the sender sees something different from the room, and
262// a newline inside a markdown table cell would break every row below it in
263// Render. Better to refuse and say why.
264func checkText(text string) {
265 if len(text) == 0 {
266 panic("chat: say something")
267 }
268 if len(text) > maxTextLen {
269 panic("chat: too long — " + strconv.Itoa(maxTextLen) + " bytes maximum")
270 }
271 if !utf8.ValidString(text) {
272 panic("chat: not valid UTF-8")
273 }
274 for i := 0; i < len(text); i++ {
275 if text[i] < 0x20 || text[i] == 0x7f {
276 panic("chat: no control characters, including line breaks")
277 }
278 }
279}
280
281// --- Reading ---------------------------------------------------------------
282
283// Count is how many messages the ring currently holds, at most RingSize.
284func Count() int64 { return int64(len(messages)) }
285
286// Seen is every message ever posted, including those the ring has
287// overwritten. The gap between this and Count is exactly what a reader must
288// go to the event log for.
289func Seen() int64 { return seen }
290
291// RingSize is the bound, exported so a front end can say "last 64" without
292// hardcoding a number that might change.
293func RingSize() int64 { return ringSize }
294
295// MaxTextLen lets a front end enforce the same cap the contract will, so a
296// too-long message is refused in the browser instead of costing gas to fail.
297func MaxTextLen() int64 { return maxTextLen }
298
299// CooldownBlocks lets a front end say how long the wait is instead of
300// surprising someone with a panic.
301func CooldownBlocks() int64 { return cooldownBlocks }
302
303// NextAllowedHeight is the first block at which addr may post again, or 0 if
304// addr has never posted. A front end can disable its button until then rather
305// than letting someone pay gas to be refused.
306func NextAllowedHeight(addr address) int64 {
307 key := string(addr)
308 if !lastPost.Has(key) {
309 return 0
310 }
311 return lastPost.Get(key).(int64) + cooldownBlocks
312}
313
314// Recent returns up to `limit` messages, MOST RECENT FIRST. limit is clamped
315// to what the ring holds, so no caller can ask for an unbounded amount of
316// work.
317func Recent(limit int64) []Message {
318 n := int64(len(messages))
319 if n == 0 {
320 return []Message{}
321 }
322 if limit <= 0 || limit > n {
323 limit = n
324 }
325 out := make([]Message, 0, limit)
326 // next points at the oldest slot once the ring is full, so walk
327 // backwards from the most recently written one.
328 i := next - 1
329 if i < 0 {
330 i = len(messages) - 1
331 }
332 for c := int64(0); c < limit; c++ {
333 out = append(out, messages[i])
334 i--
335 if i < 0 {
336 i = len(messages) - 1
337 }
338 }
339 return out
340}
341
342// --- Render ----------------------------------------------------------------
343//
344// IT MUST NEVER PANIC, WHATEVER path IS. A panicking Render does not fail
345// quietly on one request — it takes the realm's whole gnoweb page down, for
346// everyone, permanently, because a realm cannot be upgraded. So this ignores
347// `path` entirely, reads only, and loops over a bounded collection.
348//
349// EVERY MESSAGE GOES THROUGH cell(). Text is free input from a stranger and
350// this is a markdown document: unescaped, it can forge table rows, inject
351// links, or impersonate the house.
352
353func cell(s string) string {
354 out := sanitize.InlineText(s)
355 esc := ""
356 for _, r := range out {
357 if r == '|' {
358 esc += "\\|"
359 continue
360 }
361 esc += string(r)
362 }
363 return esc
364}
365
366func short(a address) string {
367 s := string(a)
368 if len(s) <= 12 {
369 return s
370 }
371 return s[:8] + "…" + s[len(s)-4:]
372}
373
374func Render(path string) string {
375 out := "# SPLASH — the room\n\n"
376 // "Nobody can delete anything" was the first draft and it was not true
377 // on its own page: the ring evicts the oldest message every time it
378 // takes a new one past the bound. What is actually true is that nobody
379 // can delete SELECTIVELY — there is no key that can remove one person's
380 // words while leaving everyone else's.
381 out += "Public, and nobody can pick out a message to remove — there is no "
382 out += "moderator key on this contract, ours included. This page keeps the "
383 out += "most recent " + strconv.Itoa(ringSize) + "; everything ever said is in the chain's "
384 out += "own log and cannot be taken back.\n\n"
385 out += "- holding: **" + strconv.FormatInt(Count(), 10) + "** of " + strconv.Itoa(ringSize) + "\n"
386 out += "- said here, ever: **" + strconv.FormatInt(seen, 10) + "**\n\n"
387
388 if len(messages) == 0 {
389 out += "_Nobody has said anything yet._\n"
390 return out
391 }
392
393 out += "| # | who | said | block |\n|---:|---|---|---:|\n"
394 for _, m := range Recent(int64(len(messages))) {
395 out += "| " + strconv.FormatInt(m.ID, 10) +
396 " | `" + short(m.Author) + "`" +
397 " | " + cell(m.Text) +
398 " | " + strconv.FormatInt(m.Height, 10) + " |\n"
399 }
400 return out
401}