guestbook.gno
3.47 Kb · 106 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.
7//
8// v1 renders through [p/moul/kit/ui](/p/moul/kit/ui/v0) instead of the
9// hand-rolled shortAddr and escapeCell helpers v0 carried. The escaping is the
10// substantive change: v0 replaced four characters, while ui.Cell also strips
11// bidi and zero-width characters, which a message can otherwise use to reorder
12// how the rest of the row reads.
13package guestbook
14
15import (
16 "strconv"
17 "strings"
18
19 "chain"
20 "chain/markdown"
21 "chain/runtime"
22
23 "gno.land/p/moul/kit/store/v0"
24 "gno.land/p/moul/kit/ui/v0"
25)
26
27// Entry is a single signed guestbook line. It carries no ID field: the id
28// belongs to the store, which hands it back on lookup and iteration.
29type Entry struct {
30 Author string // bech32 address of the signer
31 Message string // the message left by the signer
32 Height int64 // block height at which the entry was signed
33}
34
35const maxMessageLen = 280
36
37// entries assigns the sequence numbers. v1 kept its own count plus a seqKey()
38// that zero-padded to width 16, the widest of the six hand-rolled variants in
39// this repo and still a ceiling; the store key has none.
40var entries = store.Named("guestbook: entry")
41
42// Sign appends a new entry to the guestbook attributed to the immediate caller.
43//
44// It is a crossing function (gno 0.9 interrealm convention): callers invoke it
45// as Sign(cross(cur), "hello"). The cur.IsCurrent() guard is the authentication
46// primitive — without it a stale/forged realm value could spoof the author.
47func Sign(cur realm, message string) {
48 if !cur.IsCurrent() {
49 panic("guestbook: spoofed realm; Sign must be called via cross(cur)")
50 }
51
52 author := cur.Previous().Address().String()
53 id, e := addEntry(author, message, runtime.ChainHeight())
54
55 chain.Emit("Signed",
56 "id", id.String(),
57 "author", e.Author,
58 "height", strconv.FormatInt(e.Height, 10),
59 )
60}
61
62// addEntry validates the message and appends an entry. Non-crossing internal
63// helper so the append/validation logic is unit-testable without a realm frame.
64// Panics (aborting the tx, reverting state) on an invalid message.
65func addEntry(author, message string, height int64) (store.ID, *Entry) {
66 msg := strings.TrimSpace(message)
67 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(msg)) == "" {
68 panic("guestbook: message must not be empty")
69 }
70 if len(msg) > maxMessageLen {
71 panic("guestbook: message too long (max " + strconv.Itoa(maxMessageLen) + " bytes)")
72 }
73
74 e := &Entry{
75 Author: author,
76 Message: msg,
77 Height: height,
78 }
79 return entries.Add(e), e
80}
81
82// Count returns the total number of signatures. Read-only, non-crossing.
83func Count() int {
84 return entries.Len()
85}
86
87// Render lists every entry newest-first as a Markdown table plus a total count.
88func Render(path string) string {
89 if entries.Len() == 0 {
90 return "# Guestbook\n\n" + ui.Empty("No one has signed yet. Be the first!")
91 }
92
93 t := ui.NewTable("#", "Who", "Message", "Height")
94 // Ids ascend with signing, so reverse iteration is newest-first.
95 entries.EachReverse(func(id store.ID, v any) {
96 e := v.(*Entry)
97 t.Row(
98 id.String(),
99 ui.AddrOf(e.Author),
100 ui.Cell(e.Message),
101 strconv.FormatInt(e.Height, 10),
102 )
103 })
104
105 return "# Guestbook\n\n**Total signatures:** " + strconv.Itoa(entries.Len()) + "\n\n" + t.String()
106}