Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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 := capsules.Get(idKey(id))
159	if v == nil {
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}