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

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) }