Search Apps Documentation Source Content File Folder Download Copy Actions Download

timecapsule.gno

4.81 Kb · 185 lines
  1// Package timecapsule is a public guestbook where every message is sealed
  2// until a future block height, then permanently unlocked for anyone to read.
  3package timecapsule
  4
  5import (
  6	"strconv"
  7	"strings"
  8
  9	"chain"
 10	"chain/runtime"
 11
 12	"gno.land/p/nt/avl/v0"
 13	"gno.land/p/nt/markdown/sanitize/v0"
 14)
 15
 16type capsule struct {
 17	id           int64
 18	author       address
 19	message      string
 20	createdAt    int64
 21	unlockHeight int64
 22}
 23
 24var (
 25	capsules avl.Tree // key: zero-padded id -> *capsule
 26	nextID   int64
 27)
 28
 29const (
 30	maxDelayBlocks int64 = 1_000_000
 31	maxMessageLen  int   = 500
 32)
 33
 34// Leave seals a new message that stays hidden until `delayBlocks` blocks
 35// from now, then becomes publicly readable forever. Returns the capsule id.
 36func Leave(cur realm, message string, delayBlocks int64) int64 {
 37	if !cur.Previous().IsUserCall() {
 38		panic("only direct user calls can leave a capsule")
 39	}
 40
 41	message = strings.TrimSpace(message)
 42	if message == "" {
 43		panic("message must not be empty")
 44	}
 45	if len(message) > maxMessageLen {
 46		panic("message too long")
 47	}
 48	if delayBlocks <= 0 || delayBlocks > maxDelayBlocks {
 49		panic("delayBlocks out of range")
 50	}
 51
 52	now := runtime.ChainHeight()
 53	id := nextID
 54	nextID++
 55
 56	c := &capsule{
 57		id:           id,
 58		author:       cur.Previous().Address(),
 59		message:      message,
 60		createdAt:    now,
 61		unlockHeight: now + delayBlocks,
 62	}
 63	capsules.Set(idKey(id), c)
 64
 65	chain.Emit("CapsuleSealed",
 66		"id", strconv.FormatInt(id, 10),
 67		"unlockHeight", strconv.FormatInt(c.unlockHeight, 10),
 68	)
 69	return id
 70}
 71
 72func idKey(id int64) string {
 73	s := strconv.FormatInt(id, 10)
 74	return strings.Repeat("0", 12-len(s)) + s
 75}
 76
 77func Render(path string) string {
 78	switch {
 79	case path == "":
 80		return renderHome()
 81	case path == "sealed":
 82		return renderSealed()
 83	case strings.HasPrefix(path, "capsule/"):
 84		return renderCapsule(strings.TrimPrefix(path, "capsule/"))
 85	default:
 86		return "> [!WARNING]\n> Path not found\n"
 87	}
 88}
 89
 90func renderHome() string {
 91	now := runtime.ChainHeight()
 92
 93	revealed := 0
 94	sealed := 0
 95	capsules.Iterate("", "", func(_ string, value any) bool {
 96		if value.(*capsule).unlockHeight <= now {
 97			revealed++
 98		} else {
 99			sealed++
100		}
101		return false
102	})
103
104	var out strings.Builder
105	out.WriteString("# ⏳ Time Capsule Guestbook\n\n")
106	out.WriteString("Leave a message for the future. It stays sealed until its unlock block height, then anyone can read it — call `Leave(message, delayBlocks)`.\n\n")
107	out.WriteString(strconv.Itoa(revealed+sealed) + " capsule(s) total · " +
108		strconv.Itoa(revealed) + " unlocked · " + strconv.Itoa(sealed) + " still sealed")
109	if sealed > 0 {
110		out.WriteString(" (see [sealed](sealed))")
111	}
112	out.WriteString(".\n\n## Unlocked messages\n\n")
113
114	if revealed == 0 {
115		out.WriteString("_None yet. Be the first to leave one that will open later._\n")
116		return out.String()
117	}
118
119	capsules.Iterate("", "", func(_ string, value any) bool {
120		c := value.(*capsule)
121		if c.unlockHeight <= now {
122			out.WriteString(renderEntry(c))
123		}
124		return false
125	})
126	return out.String()
127}
128
129func renderSealed() string {
130	now := runtime.ChainHeight()
131
132	var out strings.Builder
133	out.WriteString("# Sealed Capsules\n\n")
134
135	found := false
136	capsules.Iterate("", "", func(_ string, value any) bool {
137		c := value.(*capsule)
138		if c.unlockHeight > now {
139			found = true
140			out.WriteString("- capsule [#" + strconv.FormatInt(c.id, 10) + "](capsule/" +
141				strconv.FormatInt(c.id, 10) + ") by `" + c.author.String() +
142				"` — unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) +
143				" (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left)\n")
144		}
145		return false
146	})
147	if !found {
148		out.WriteString("_No sealed capsules right now._\n")
149	}
150	return out.String()
151}
152
153func renderCapsule(idStr string) string {
154	id, err := strconv.ParseInt(idStr, 10, 64)
155	if err != nil {
156		return "> [!WARNING]\n> Invalid capsule id\n"
157	}
158	v, ok := capsules.Get(idKey(id))
159	if !ok {
160		return "> [!WARNING]\n> Capsule not found\n"
161	}
162	c := v.(*capsule)
163	now := runtime.ChainHeight()
164
165	var out strings.Builder
166	out.WriteString("# Capsule #" + strconv.FormatInt(c.id, 10) + "\n\n")
167	out.WriteString("Sealed by `" + c.author.String() + "` at block " + strconv.FormatInt(c.createdAt, 10) + ".\n\n")
168
169	if c.unlockHeight > now {
170		out.WriteString("\U0001f512 Still sealed. Unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) +
171			" (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left).\n")
172		return out.String()
173	}
174
175	out.WriteString("\U0001f513 Unlocked at block " + strconv.FormatInt(c.unlockHeight, 10) + ":\n\n> " +
176		sanitize.InlineText(c.message) + "\n")
177	return out.String()
178}
179
180func renderEntry(c *capsule) string {
181	return "- **#" + strconv.FormatInt(c.id, 10) + "** by `" + c.author.String() +
182		"` (sealed at block " + strconv.FormatInt(c.createdAt, 10) +
183		", opened at block " + strconv.FormatInt(c.unlockHeight, 10) + "):\n  > " +
184		sanitize.InlineText(c.message) + "\n\n"
185}