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