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

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}