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