timecapsule.gno
4.91 Kb · 176 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/moul/kit/store/v0"
13 "gno.land/p/nt/markdown/sanitize/v0"
14)
15
16// capsule carries no id field: the id belongs to the store, which hands it
17// back on lookup and iteration.
18type capsule struct {
19 author address
20 message string
21 createdAt int64
22 unlockHeight int64
23}
24
25// capsules assigns the capsule ids. v0 kept its own nextID plus an idKey()
26// that called strings.Repeat WITHOUT the length guard the other realms had, so
27// at the 10^12th capsule it did not mis-sort, it panicked: Repeat rejects a
28// negative count, and the realm would have stopped accepting Leave calls. The
29// store key is 8 fixed bytes with no width to outgrow.
30var capsules = store.Named("capsule")
31
32const (
33 maxDelayBlocks int64 = 1_000_000
34 maxMessageLen int = 500
35)
36
37// Leave seals a new message that stays hidden until `delayBlocks` blocks
38// from now, then becomes publicly readable forever. Returns the capsule id.
39func Leave(cur realm, message string, delayBlocks int64) int64 {
40 if !cur.Previous().IsUserCall() {
41 panic("only direct user calls can leave a capsule")
42 }
43
44 message = strings.TrimSpace(message)
45 if message == "" {
46 panic("message must not be empty")
47 }
48 if len(message) > maxMessageLen {
49 panic("message too long")
50 }
51 if delayBlocks <= 0 || delayBlocks > maxDelayBlocks {
52 panic("delayBlocks out of range")
53 }
54
55 now := runtime.ChainHeight()
56 c := &capsule{
57 author: cur.Previous().Address(),
58 message: message,
59 createdAt: now,
60 unlockHeight: now + delayBlocks,
61 }
62 id := capsules.Add(c)
63
64 chain.Emit("CapsuleSealed",
65 "id", id.String(),
66 "unlockHeight", strconv.FormatInt(c.unlockHeight, 10),
67 )
68 return int64(id)
69}
70
71func Render(path string) string {
72 switch {
73 case path == "":
74 return renderHome()
75 case path == "sealed":
76 return renderSealed()
77 case strings.HasPrefix(path, "capsule/"):
78 return renderCapsule(strings.TrimPrefix(path, "capsule/"))
79 default:
80 return "> [!WARNING]\n> Path not found\n"
81 }
82}
83
84func renderHome() string {
85 now := runtime.ChainHeight()
86
87 revealed := 0
88 sealed := 0
89 capsules.Each(func(_ store.ID, value any) {
90 if value.(*capsule).unlockHeight <= now {
91 revealed++
92 } else {
93 sealed++
94 }
95 })
96
97 var out strings.Builder
98 out.WriteString("# ⏳ Time Capsule Guestbook\n\n")
99 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")
100 out.WriteString(strconv.Itoa(revealed+sealed) + " capsule(s) total · " +
101 strconv.Itoa(revealed) + " unlocked · " + strconv.Itoa(sealed) + " still sealed")
102 if sealed > 0 {
103 out.WriteString(" (see [sealed](sealed))")
104 }
105 out.WriteString(".\n\n## Unlocked messages\n\n")
106
107 if revealed == 0 {
108 out.WriteString("_None yet. Be the first to leave one that will open later._\n")
109 return out.String()
110 }
111
112 capsules.Each(func(id store.ID, value any) {
113 c := value.(*capsule)
114 if c.unlockHeight <= now {
115 out.WriteString(renderEntry(id, c))
116 }
117 })
118 return out.String()
119}
120
121func renderSealed() string {
122 now := runtime.ChainHeight()
123
124 var out strings.Builder
125 out.WriteString("# Sealed Capsules\n\n")
126
127 found := false
128 capsules.Each(func(id store.ID, value any) {
129 c := value.(*capsule)
130 if c.unlockHeight > now {
131 found = true
132 out.WriteString("- capsule [#" + id.String() + "](capsule/" +
133 id.String() + ") by `" + c.author.String() +
134 "` — unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) +
135 " (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left)\n")
136 }
137 })
138 if !found {
139 out.WriteString("_No sealed capsules right now._\n")
140 }
141 return out.String()
142}
143
144func renderCapsule(idStr string) string {
145 id, ok := store.ParseID(idStr)
146 if !ok {
147 return "> [!WARNING]\n> Invalid capsule id\n"
148 }
149 v, ok := capsules.Get(id)
150 if !ok {
151 return "> [!WARNING]\n> Capsule not found\n"
152 }
153 c := v.(*capsule)
154 now := runtime.ChainHeight()
155
156 var out strings.Builder
157 out.WriteString("# Capsule #" + id.String() + "\n\n")
158 out.WriteString("Sealed by `" + c.author.String() + "` at block " + strconv.FormatInt(c.createdAt, 10) + ".\n\n")
159
160 if c.unlockHeight > now {
161 out.WriteString("\U0001f512 Still sealed. Unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) +
162 " (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left).\n")
163 return out.String()
164 }
165
166 out.WriteString("\U0001f513 Unlocked at block " + strconv.FormatInt(c.unlockHeight, 10) + ":\n\n> " +
167 sanitize.InlineText(c.message) + "\n")
168 return out.String()
169}
170
171func renderEntry(id store.ID, c *capsule) string {
172 return "- **#" + id.String() + "** by `" + c.author.String() +
173 "` (sealed at block " + strconv.FormatInt(c.createdAt, 10) +
174 ", opened at block " + strconv.FormatInt(c.unlockHeight, 10) + "):\n > " +
175 sanitize.InlineText(c.message) + "\n\n"
176}