Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}