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}