// Package guestbook is a public, on-chain guestbook realm for gno.land.
//
// Anyone can sign the guestbook with a short message via the Sign crossing
// function. Every entry records the caller's address, the message, and the
// block height at which it was signed. Render lists all entries newest-first
// as a Markdown table.
package guestbook
import (
"strconv"
"strings"
"chain"
"chain/runtime"
"gno.land/p/nt/avl/v0"
)
// Entry is a single signed guestbook line.
type Entry struct {
ID int // 1-based sequence number
Author string // bech32 address of the signer
Message string // the message left by the signer
Height int64 // block height at which the entry was signed
}
const maxMessageLen = 280
var (
// entries is keyed by a zero-padded sequence so avl iteration is ordered.
entries = avl.NewTree()
count int
)
// Sign appends a new entry to the guestbook attributed to the immediate caller.
//
// It is a crossing function (gno 0.9 interrealm convention): callers invoke it
// as Sign(cross(cur), "hello"). The cur.IsCurrent() guard is the authentication
// primitive — without it a stale/forged realm value could spoof the author.
func Sign(cur realm, message string) {
if !cur.IsCurrent() {
panic("guestbook: spoofed realm; Sign must be called via cross(cur)")
}
author := cur.Previous().Address().String()
e := addEntry(author, message, runtime.ChainHeight())
chain.Emit("Signed",
"id", strconv.Itoa(e.ID),
"author", e.Author,
"height", strconv.FormatInt(e.Height, 10),
)
}
// addEntry validates the message and appends an entry. Non-crossing internal
// helper so the append/validation logic is unit-testable without a realm frame.
// Panics (aborting the tx, reverting state) on an invalid message.
func addEntry(author, message string, height int64) *Entry {
msg := strings.TrimSpace(message)
if msg == "" {
panic("guestbook: message must not be empty")
}
if len(msg) > maxMessageLen {
panic("guestbook: message too long (max " + strconv.Itoa(maxMessageLen) + " bytes)")
}
count++
e := &Entry{
ID: count,
Author: author,
Message: msg,
Height: height,
}
entries.Set(seqKey(count), e)
return e
}
// Count returns the total number of signatures. Read-only, non-crossing.
func Count() int {
return count
}
// Render lists every entry newest-first as a Markdown table plus a total count.
func Render(path string) string {
var b strings.Builder
b.WriteString("# Guestbook\n\n")
if count == 0 {
b.WriteString("_No one has signed yet. Be the first!_\n")
return b.String()
}
b.WriteString("**Total signatures:** ")
b.WriteString(strconv.Itoa(count))
b.WriteString("\n\n")
b.WriteString("| # | Who | Message | Height |\n")
b.WriteString("|---|-----|---------|--------|\n")
// avl iterates ascending by key; ReverseIterate gives newest-first.
entries.ReverseIterate("", "", func(_ string, v any) bool {
e := v.(*Entry)
b.WriteString("| ")
b.WriteString(strconv.Itoa(e.ID))
b.WriteString(" | ")
b.WriteString(shortAddr(e.Author))
b.WriteString(" | ")
b.WriteString(escapeCell(e.Message))
b.WriteString(" | ")
b.WriteString(strconv.FormatInt(e.Height, 10))
b.WriteString(" |\n")
return false
})
return b.String()
}
// seqKey renders a sequence number as a fixed-width, zero-padded string so
// avl's lexicographic key order matches numeric order.
func seqKey(n int) string {
s := strconv.Itoa(n)
const width = 16
if len(s) >= width {
return s
}
return strings.Repeat("0", width-len(s)) + s
}
// shortAddr abbreviates a bech32 address for compact display.
func shortAddr(addr string) string {
if len(addr) <= 12 {
return addr
}
return addr[:8] + "…" + addr[len(addr)-4:]
}
// escapeCell neutralizes characters that would break a Markdown table cell.
func escapeCell(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return s
}