duebook_demo.gno
16.97 Kb · 527 lines
1// Package duebook_demo is the reference consumer for the duebook
2// scheduling primitive. It exists to make duebook's central claim
3// falsifiable on a live chain.
4//
5// That claim is: a deferral claimed in transaction N is refused in
6// transaction N+1, forever. A pure package cannot demonstrate this — a
7// vm/qeval is a single ephemeral evaluation with no state that survives
8// it. Only a realm, called twice, can show it. So this realm is not a
9// decoration on the primitive; it is the experiment.
10//
11// # What it does
12//
13// Anyone schedules an announcement to be published no earlier than
14// delayBlocks from now. Once due, ANYONE may publish it — publication is
15// deliberately permissionless, so that what protects the announcement from
16// being published twice is duebook's exactly-once Claim and nothing else.
17// If an access-control list were guarding Publish, the experiment would
18// prove nothing about duebook.
19//
20// The scheduler may cancel at any time before publication. Once the
21// optional time-to-live elapses, the announcement can never be published
22// and anyone may clear it to free a slot.
23//
24// # How it wires duebook correctly
25//
26// This realm is also the worked example of duebook's consumer contract:
27//
28// - the clock is runtime.ChainHeight(), never a call argument (contract 1);
29// - heights are the only unit used (contract 2);
30// - the *Book is an unexported package-level var and is never returned
31// across a realm boundary (contract 3);
32// - the owner is cur.Previous().Address(), never a parameter (contract 4);
33// - Publish performs its effect immediately after a successful Claim, in
34// the same transaction (contract 5).
35//
36// This realm holds no coins and has no admin. Every entrypoint rejects
37// attached coins.
38package duebook_demo
39
40import (
41 "chain"
42 "chain/runtime"
43 "chain/runtime/unsafe"
44 "strconv"
45
46 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/duebook"
47 "gno.land/p/nt/avl/v0"
48 "gno.land/p/nt/markdown/sanitize/v0"
49)
50
51const (
52 // MinDelayBlocks is the floor on how far ahead an announcement may be
53 // scheduled. One block is enough to prove the delay is enforced while
54 // keeping the realm exercisable on a live chain.
55 MinDelayBlocks = int64(1)
56
57 // MaxDelayBlocks caps scheduling roughly 100 days out at pearl-1's
58 // ~3.42s blocks. It also bounds now+delay well inside int64.
59 MaxDelayBlocks = int64(2_500_000)
60
61 // MaxTTLBlocks caps how long a due announcement stays publishable.
62 MaxTTLBlocks = int64(2_500_000)
63
64 // MaxOpen caps simultaneously scheduled announcements, bounding both
65 // storage and the cost of the Due/Expirable scans.
66 MaxOpen = 200
67
68 // MaxTextLen bounds one announcement. It is a BYTE length, not a rune
69 // count, because its job is to bound storage; a multi-byte script
70 // therefore gets fewer than 280 visible characters.
71 MaxTextLen = 280
72
73 // MaxPublishedKept is how many recent publications Render shows. The
74 // permanent record is the emitted event; this is a bounded window so
75 // realm storage cannot grow without limit.
76 MaxPublishedKept = 50
77
78 // MaxListed caps how many entries a single query returns.
79 MaxListed = 50
80)
81
82// book holds the scheduled announcements. It is unexported and never
83// returned: handing a *Book across a realm boundary would hand out the
84// right to schedule, cancel and claim (duebook consumer contract 3).
85var book = duebook.MustNew(MinDelayBlocks, MaxDelayBlocks, MaxOpen)
86
87// publication is one published announcement, kept for rendering.
88type publication struct {
89 seq int64
90 deferralID int64
91 scheduler address
92 publisher address
93 text string
94 scheduledAt int64
95 dueAt int64
96 publishedAt int64
97}
98
99var (
100 published = avl.NewTree() // padded seq -> *publication
101 publishedTotal int64 // every publication ever, including evicted
102 nextSeq int64 = 1
103)
104
105// rejectStraySend aborts if an EOA attached coins to an entrypoint that
106// has no use for them. This realm never holds value; without this guard a
107// mistyped send would be silently absorbed by the realm bank.
108func rejectStraySend(cur realm) {
109 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
110 panic("this entrypoint does not accept coins")
111 }
112}
113
114// Schedule registers an announcement to be published no earlier than
115// delayBlocks from the current height, and returns its deferral ID.
116//
117// ttlBlocks is how long after the due height the announcement stays
118// publishable; 0 means it never expires. The caller becomes the owner and
119// is the only account that may Cancel it.
120func Schedule(cur realm, text string, delayBlocks, ttlBlocks int64) string {
121 rejectStraySend(cur)
122
123 if text == "" {
124 panic("announcement text must not be empty")
125 }
126 if len(text) > MaxTextLen {
127 panic("announcement text exceeds " + itoa(MaxTextLen) + " bytes")
128 }
129 if ttlBlocks < 0 || ttlBlocks > MaxTTLBlocks {
130 panic("ttlBlocks out of range [0, " + itoa(MaxTTLBlocks) + "]")
131 }
132
133 owner := cur.Previous().Address()
134 // The clock is the chain's, never an argument (consumer contract 1).
135 now := runtime.ChainHeight()
136
137 // duebook validates delayBlocks against the book's own bounds and
138 // returns an error rather than panicking; a realm turns that into an
139 // aborted transaction so nothing is half-applied.
140 id, err := book.Schedule(owner.String(), text, now, delayBlocks, ttlBlocks)
141 if err != nil {
142 panic(err)
143 }
144
145 d, _ := book.Get(id)
146 chain.Emit("Scheduled",
147 "id", utoa(id),
148 "scheduler", owner.String(),
149 "dueAt", itoa(d.DueAt),
150 "expiresAt", itoa(d.ExpiresAt),
151 )
152 return utoa(id)
153}
154
155// Publish publishes a scheduled announcement once it is due. It is
156// deliberately permissionless: duebook's exactly-once Claim is the only
157// thing preventing a second publication, which is precisely what this
158// realm exists to demonstrate.
159//
160// Aborts if the announcement does not exist, is not due yet, has expired,
161// or has already been consumed by a publication or a cancellation.
162func Publish(cur realm, id int64) string {
163 rejectStraySend(cur)
164
165 did := mustID(id)
166 now := runtime.ChainHeight()
167
168 // CONSUME FIRST. Claim removes the deferral before returning, so by
169 // the time the effect below runs, no second Claim of this ID can ever
170 // succeed — in this transaction or any future one.
171 d, err := book.Claim(did, now)
172 if err != nil {
173 panic(err)
174 }
175
176 // EFFECT SECOND, same transaction (consumer contract 5).
177 publisher := cur.Previous().Address()
178 seq := nextSeq
179 nextSeq++
180 publishedTotal++
181
182 published.Set(seqKey(seq), &publication{
183 seq: seq,
184 deferralID: int64(d.ID),
185 scheduler: address(d.Owner),
186 publisher: publisher,
187 text: d.Payload,
188 scheduledAt: d.CreatedAt,
189 dueAt: d.DueAt,
190 publishedAt: now,
191 })
192 evictOldest()
193
194 chain.Emit("Published",
195 "id", utoa(d.ID),
196 "seq", itoa(seq),
197 "scheduler", d.Owner,
198 "publisher", publisher.String(),
199 "height", itoa(now),
200 )
201 return "published " + utoa(d.ID) + " at height " + itoa(now)
202}
203
204// Cancel withdraws a scheduled announcement before it is published. Only
205// the account that scheduled it may cancel, and only while it is still
206// open — a cancellation that arrives after publication aborts.
207func Cancel(cur realm, id int64) string {
208 rejectStraySend(cur)
209
210 did := mustID(id)
211 caller := cur.Previous().Address()
212
213 // Ownership is checked by duebook against the address this realm
214 // recorded at Schedule, never against a caller-supplied argument.
215 d, err := book.Cancel(did, caller.String())
216 if err != nil {
217 panic(err)
218 }
219
220 chain.Emit("Cancelled", "id", utoa(d.ID), "scheduler", d.Owner)
221 return "cancelled " + utoa(d.ID)
222}
223
224// Expire clears an announcement whose time-to-live has elapsed, freeing
225// its slot against MaxOpen. It is permissionless because an expired
226// announcement can never be published, so there is nothing left to
227// protect and everything to gain from letting anyone tidy up.
228func Expire(cur realm, id int64) string {
229 rejectStraySend(cur)
230
231 did := mustID(id)
232 now := runtime.ChainHeight()
233
234 d, err := book.Expire(did, now)
235 if err != nil {
236 panic(err)
237 }
238
239 chain.Emit("Expired", "id", utoa(d.ID), "scheduler", d.Owner, "height", itoa(now))
240 return "expired " + utoa(d.ID)
241}
242
243// --- read-only views -------------------------------------------------------
244// None of these authorize anything: only Publish's successful Claim does.
245
246// Height returns the chain height this realm is reading as "now".
247func Height() int64 { return runtime.ChainHeight() }
248
249// OpenCount returns how many announcements are currently scheduled.
250func OpenCount() int { return book.OpenCount() }
251
252// PublishedTotal returns how many announcements have ever been published,
253// including ones since evicted from the rendered window.
254func PublishedTotal() int64 { return publishedTotal }
255
256// NextID returns the deferral ID the next Schedule will allocate. It only
257// ever increases; that is what makes a consumed ID permanently unusable.
258func NextID() string { return utoa(book.NextID()) }
259
260// Status describes a scheduled announcement: its window, and whether it is
261// publishable right now. Returns "unknown" when the ID was never issued or
262// has already been consumed — duebook keeps no tombstone, so those two
263// cases are indistinguishable by design.
264func Status(id int64) string {
265 if id < 1 {
266 return "unknown"
267 }
268 d, ok := book.Get(uint64(id))
269 if !ok {
270 return "unknown"
271 }
272 now := runtime.ChainHeight()
273 state := "pending"
274 switch {
275 case d.IsExpired(now):
276 state = "expired"
277 case d.IsDue(now):
278 state = "publishable"
279 }
280 out := state +
281 " scheduler=" + d.Owner +
282 " dueAt=" + itoa(d.DueAt) +
283 " now=" + itoa(now)
284 if d.ExpiresAt == 0 {
285 return out + " expiresAt=never"
286 }
287 return out + " expiresAt=" + itoa(d.ExpiresAt)
288}
289
290// DueNow returns the IDs of announcements publishable at the current
291// height, oldest first, capped at MaxListed.
292func DueNow() string {
293 ds := book.Due(runtime.ChainHeight(), MaxListed)
294 if len(ds) == 0 {
295 return ""
296 }
297 out := ""
298 for i, d := range ds {
299 if i > 0 {
300 out += ","
301 }
302 out += utoa(d.ID)
303 }
304 return out
305}
306
307// ExpirableNow returns the IDs that Expire would accept at the current
308// height, oldest first, capped at MaxListed.
309func ExpirableNow() string {
310 ds := book.Expirable(runtime.ChainHeight(), MaxListed)
311 if len(ds) == 0 {
312 return ""
313 }
314 out := ""
315 for i, d := range ds {
316 if i > 0 {
317 out += ","
318 }
319 out += utoa(d.ID)
320 }
321 return out
322}
323
324// --- rendering -------------------------------------------------------------
325
326// realmPath is this realm's gnoweb prefix, used to build view links.
327const realmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/duebook_demo"
328
329// Render serves three views, selected by path. Anyone may Schedule, so
330// the default view must not be something a stranger can inflate: it
331// shows only what is publishable right now, capped at MaxListed. The
332// full scheduled table lives behind :open and is capped too, so no view
333// is unbounded and none of them is the landing page by default.
334//
335// (empty) summary + publishable now + recent publications
336// open every scheduled announcement, up to MaxListed rows
337// about what this realm is and why it exists
338func Render(path string) string {
339 switch normalizePath(path) {
340 case "open":
341 return renderHeader() + renderOpen()
342 case "about":
343 return renderHeader() + renderAbout()
344 default:
345 return renderHeader() + renderDue() + renderPublished()
346 }
347}
348
349func renderHeader() string {
350 now := runtime.ChainHeight()
351 s := "# Scheduled announcements\n\n"
352 s += "Reference consumer for [duebook](/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/duebook).\n\n"
353 s += "- height: " + itoa(now) + "\n"
354 s += "- scheduled: " + itoa(int64(book.OpenCount())) + " / " + itoa(int64(MaxOpen)) + "\n"
355 s += "- published (total): " + itoa(publishedTotal) + "\n"
356 s += "- next id: " + utoa(book.NextID()) + "\n\n"
357 s += "[summary](" + realmPath + ") · "
358 s += "[all scheduled](" + realmPath + ":open) · "
359 s += "[about](" + realmPath + ":about)\n\n"
360 return s
361}
362
363// renderDue lists only what can be published at this height. book.Due
364// applies the cap itself, so the slice is at most MaxListed long.
365func renderDue() string {
366 now := runtime.ChainHeight()
367 ds := book.Due(now, MaxListed)
368
369 s := "## Publishable now\n\n"
370 if len(ds) == 0 {
371 s += "_nothing is due at height " + itoa(now) + "._\n\n"
372 return s
373 }
374 s += renderRows(ds, now)
375 if book.OpenCount() > len(ds) {
376 s += "\n" + itoa(int64(book.OpenCount()-len(ds))) +
377 " more scheduled — see [all scheduled](" + realmPath + ":open).\n"
378 }
379 return s + "\n"
380}
381
382// renderOpen lists the whole book, stopping at MaxListed rows so this
383// view has a fixed ceiling independent of MaxOpen.
384func renderOpen() string {
385 now := runtime.ChainHeight()
386
387 s := "## Scheduled\n\n"
388 if book.OpenCount() == 0 {
389 return s + "_none_\n\n"
390 }
391 ds := []duebook.Deferral{}
392 book.IterateOpen(func(d duebook.Deferral) bool {
393 ds = append(ds, d)
394 return len(ds) >= MaxListed
395 })
396 s += renderRows(ds, now)
397 if book.OpenCount() > len(ds) {
398 s += "\nShowing the first " + itoa(int64(len(ds))) + " of " +
399 itoa(int64(book.OpenCount())) + ".\n"
400 }
401 return s + "\n"
402}
403
404func renderRows(ds []duebook.Deferral, now int64) string {
405 s := "| id | scheduler | due at | expires | state |\n"
406 s += "|---|---|---|---|---|\n"
407 for _, d := range ds {
408 state := "pending"
409 switch {
410 case d.IsExpired(now):
411 state = "expired"
412 case d.IsDue(now):
413 state = "**publishable**"
414 }
415 exp := "never"
416 if d.ExpiresAt != 0 {
417 exp = itoa(d.ExpiresAt)
418 }
419 s += "| " + utoa(d.ID) +
420 " | " + sanitize.InlineText(d.Owner) +
421 " | " + itoa(d.DueAt) +
422 " | " + exp +
423 " | " + state + " |\n"
424 }
425 return s
426}
427
428// renderPublished shows the retained window, which evictOldest already
429// holds at MaxPublishedKept entries.
430func renderPublished() string {
431 s := "## Published\n\n"
432 if published.Size() == 0 {
433 return s + "_none_\n\n"
434 }
435 s += "Most recent " + itoa(int64(MaxPublishedKept)) + " shown; the full record is in the emitted events.\n\n"
436 published.Iterate("", "", func(_ string, v any) bool {
437 p := v.(*publication)
438 s += "**#" + itoa(p.seq) + "** (deferral " + itoa(p.deferralID) + ")"
439 s += " scheduled at " + itoa(p.scheduledAt)
440 s += ", due " + itoa(p.dueAt)
441 s += ", published " + itoa(p.publishedAt) + "\n\n"
442 // Blockquote, not InlineText: the slot IS a blockquote, and
443 // the helper must be the one whose contract covers the slot.
444 // InlineText happens to fold newlines today, which would
445 // contain the payload by side effect — relying on that makes
446 // safety version-bound to a helper that never promised it.
447 // Blockquote emits its own "\n" ... "\n\n" envelope, so no
448 // manual "> " prefix here (the helpers are not idempotent).
449 s += sanitize.Blockquote(p.text)
450 s += "by " + sanitize.InlineText(p.scheduler.String())
451 s += " · published by " + sanitize.InlineText(p.publisher.String()) + "\n\n"
452 return false
453 })
454 return s
455}
456
457func renderAbout() string {
458 return "## About\n\n" +
459 "Anyone may schedule an announcement to be published no earlier than " +
460 "`delayBlocks` from now. Once due, **anyone** may publish it — " +
461 "publication is deliberately permissionless, so the only thing " +
462 "preventing a second publication is duebook's exactly-once `Claim`. " +
463 "That is the experiment this realm exists to run.\n\n" +
464 "The scheduler may cancel any time before publication. Once the " +
465 "optional time-to-live elapses the announcement can never be " +
466 "published, and anyone may clear it to free a slot.\n\n" +
467 "This realm holds no coins and has no admin. Every entrypoint " +
468 "rejects attached coins.\n\n" +
469 "- min / max delay: " + itoa(MinDelayBlocks) + " / " + itoa(MaxDelayBlocks) + " blocks\n" +
470 "- max time-to-live: " + itoa(MaxTTLBlocks) + " blocks\n" +
471 "- max open at once: " + itoa(int64(MaxOpen)) + "\n" +
472 "- max announcement length: " + itoa(int64(MaxTextLen)) + " bytes\n"
473}
474
475// normalizePath reduces a gnoweb render path to a bare view name, so
476// "", "/", "open" and "/open/" all select the same view.
477func normalizePath(path string) string {
478 for len(path) > 0 && path[0] == '/' {
479 path = path[1:]
480 }
481 for len(path) > 0 && path[len(path)-1] == '/' {
482 path = path[:len(path)-1]
483 }
484 return path
485}
486
487// --- helpers ---------------------------------------------------------------
488
489// mustID converts an exported int64 ID to duebook's uint64 form. IDs start
490// at 1, so anything below that was never issued.
491func mustID(id int64) uint64 {
492 if id < 1 {
493 panic("id must be positive")
494 }
495 return uint64(id)
496}
497
498// evictOldest trims the rendered window to MaxPublishedKept, dropping the
499// lowest sequence numbers first. Evicted publications remain in the
500// emitted events.
501func evictOldest() {
502 for published.Size() > MaxPublishedKept {
503 oldest := ""
504 published.Iterate("", "", func(key string, _ any) bool {
505 oldest = key
506 return true // first key in ascending order
507 })
508 if oldest == "" {
509 return
510 }
511 published.Remove(oldest)
512 }
513}
514
515// seqKey encodes a sequence number so avl's lexical order matches numeric
516// order. Sequences are positive int64, at most 19 digits.
517func seqKey(seq int64) string {
518 s := strconv.FormatInt(seq, 10)
519 const width = 19
520 if len(s) >= width {
521 return s
522 }
523 return "0000000000000000000"[:width-len(s)] + s
524}
525
526func itoa(n int64) string { return strconv.FormatInt(n, 10) }
527func utoa(n uint64) string { return strconv.FormatUint(n, 10) }