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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

duebook.gno

16.18 Kb · 483 lines
  1// Package duebook is a pure scheduling primitive for realms that need to
  2// authorize an action now and perform it later. It is to *time* what
  3// feeledger is to *money*: it holds no coins, performs no effects, imports
  4// no chain APIs, and owns no package-level state. The importing realm owns
  5// a *Book, supplies the clock, and performs its own effects.
  6//
  7// # The one idea
  8//
  9// A realm does not ask duebook to execute anything — Gno has no autonomous
 10// execution, so "scheduled" always means "someone sends a transaction
 11// later". What a realm actually needs is for that later transaction to be
 12// authorized exactly once. Claim is that step:
 13//
 14//	d, err := book.Claim(id, now)
 15//	if err != nil {
 16//		panic(err) // not due, expired, cancelled, or already claimed
 17//	}
 18//	// ... the realm performs its own effect here, under its own authority
 19//
 20// Claim checks due / not-expired / still-open and CONSUMES the deferral in
 21// the same call, before returning. The realm then acts. Replay is not
 22// guarded against, it is structurally impossible:
 23//
 24//   - IDs are allocated monotonically from an internal counter and are
 25//     NEVER reused, for the lifetime of the Book;
 26//   - a successful Claim removes the deferral before returning;
 27//
 28// so at most one Claim per ID can ever succeed, across all transactions,
 29// forever. A second Claim of the same ID returns ErrNotFound whether the
 30// first one happened in this transaction or a year ago.
 31//
 32// Because the realm performs the effect itself, no closure, callback, or
 33// capability ever crosses a realm boundary. duebook cannot be handed code
 34// to run, so it cannot be tricked into running the wrong code.
 35//
 36// # Lifecycle
 37//
 38// A deferral is open from Schedule until exactly one of Claim, Cancel, or
 39// Expire consumes it. There is no other transition and no way back.
 40//
 41//	Schedule ──> open ──┬── Claim   (now >= DueAt, before ExpiresAt)
 42//	                    ├── Cancel  (owner only, any time while open)
 43//	                    └── Expire  (anyone, once now >= ExpiresAt)
 44//
 45// Claimability is the half-open interval [DueAt, ExpiresAt): due at DueAt,
 46// no longer claimable at ExpiresAt. A deferral scheduled with ttl == 0
 47// never expires and has ExpiresAt == 0.
 48//
 49// # State growth
 50//
 51// Consumed deferrals are removed, not archived — the ID counter, not a
 52// tombstone, is what prevents replay, so there is nothing to keep. Open
 53// deferrals are capped per Book at construction. Storage is therefore
 54// bounded by maxOpen regardless of how many deferrals have ever existed.
 55// Audit history belongs in the consuming realm's events.
 56//
 57// # Consumer contract (the parts the package cannot enforce)
 58//
 59//  1. SUPPLY A REAL CLOCK. duebook cannot verify that `now` came from
 60//     runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller
 61//     choose `now` has no delay at all. Pass the chain's clock, never a
 62//     transaction parameter. This is the single most important obligation
 63//     and the one most commonly got wrong.
 64//  2. USE ONE CLOCK CONSISTENTLY. Heights and seconds must not be mixed
 65//     within a Book; delay, ttl and now are all in the caller's chosen
 66//     unit.
 67//  3. DO NOT EXPORT THE BOOK. A *Book is a mutable handle. Returning one
 68//     across a realm boundary hands out the right to schedule, cancel and
 69//     claim. Expose your own crossing functions instead; this package
 70//     returns Deferral values, never pointers into its state.
 71//  4. AUTHORIZE THE ACTOR. duebook authenticates nothing but ownership on
 72//     Cancel. Who may Schedule, and who may Claim, are the realm's policy —
 73//     derive the caller from cur.Previous().Address(), not from an argument.
 74//  5. ACT AFTER A SUCCESSFUL CLAIM, IN THE SAME TRANSACTION. Claim's
 75//     return value is the authorization. Storing it to act on later
 76//     reintroduces the replay window this package exists to close.
 77//
 78// All failures are returned as errors and leave the Book COMPLETELY
 79// UNCHANGED. Must* wrappers are the only functions here that panic.
 80//
 81// The Book is address-agnostic: owners are non-empty strings. Realms
 82// normally use address.String().
 83package duebook
 84
 85import (
 86	"errors"
 87	"strconv"
 88
 89	"gno.land/p/nt/avl/v0"
 90)
 91
 92// MaxPayloadLen bounds a single deferral's payload. The payload is opaque
 93// to duebook — it exists so a realm can recover what it scheduled without
 94// keeping a parallel table.
 95const MaxPayloadLen = 4096
 96
 97// MaxOpenLimit is the largest maxOpen a Book may be constructed with. It
 98// bounds the worst-case cost of Due and IterateOpen, which scan the open
 99// set.
100const MaxOpenLimit = 10000
101
102// Errors returned by Book operations.
103var (
104	ErrEmptyOwner    = errors.New("duebook: empty owner key")
105	ErrPayloadTooBig = errors.New("duebook: payload exceeds MaxPayloadLen")
106	ErrInvalidNow    = errors.New("duebook: now must be non-negative")
107	ErrInvalidDelay  = errors.New("duebook: delay outside [minDelay, maxDelay]")
108	ErrInvalidTTL    = errors.New("duebook: ttl must be non-negative")
109	ErrInvalidConfig = errors.New("duebook: invalid book configuration")
110	ErrBookFull      = errors.New("duebook: open deferral cap reached")
111	ErrNotFound      = errors.New("duebook: no such open deferral")
112	ErrNotDue        = errors.New("duebook: not due yet")
113	ErrExpired       = errors.New("duebook: deferral has expired")
114	ErrNotExpired    = errors.New("duebook: deferral has not expired")
115	ErrNotOwner      = errors.New("duebook: caller does not own this deferral")
116	ErrOverflow      = errors.New("duebook: int64 overflow")
117	ErrIDExhausted   = errors.New("duebook: identifier space exhausted")
118)
119
120// Deferral is a scheduled action. It is returned BY VALUE: holders cannot
121// reach into a Book through it. Payload is opaque to this package.
122//
123// ExpiresAt == 0 means the deferral never expires. Otherwise the deferral
124// is claimable exactly on [DueAt, ExpiresAt).
125type Deferral struct {
126	ID        uint64
127	Owner     string
128	Payload   string
129	CreatedAt int64
130	DueAt     int64
131	ExpiresAt int64
132}
133
134// IsDue reports whether the deferral has reached its due time at now.
135func (d Deferral) IsDue(now int64) bool { return now >= d.DueAt }
136
137// IsExpired reports whether the deferral is past its expiry at now.
138// A deferral with no expiry is never expired.
139func (d Deferral) IsExpired(now int64) bool {
140	return d.ExpiresAt != 0 && now >= d.ExpiresAt
141}
142
143// IsClaimable reports whether Claim would succeed at now, assuming the
144// deferral is still open.
145func (d Deferral) IsClaimable(now int64) bool {
146	return d.IsDue(now) && !d.IsExpired(now)
147}
148
149// Book holds the open deferrals of one consuming realm. The zero value is
150// not usable; construct with New.
151type Book struct {
152	minDelay int64
153	maxDelay int64
154	maxOpen  int
155	nextID   uint64    // never decreases; IDs are never reused
156	open     *avl.Tree // padded id -> Deferral
157}
158
159// New returns an empty Book.
160//
161// minDelay is the floor on how far ahead a deferral may be scheduled; 0
162// permits same-instant scheduling. maxDelay is the ceiling, and doubles as
163// the overflow guard on now+delay. maxOpen caps simultaneously open
164// deferrals and must be in [1, MaxOpenLimit].
165//
166// Units are the caller's choice — block heights or seconds — but must be
167// used consistently for the life of the Book.
168func New(minDelay, maxDelay int64, maxOpen int) (*Book, error) {
169	if minDelay < 0 || maxDelay < minDelay {
170		return nil, ErrInvalidConfig
171	}
172	if maxOpen < 1 || maxOpen > MaxOpenLimit {
173		return nil, ErrInvalidConfig
174	}
175	return &Book{
176		minDelay: minDelay,
177		maxDelay: maxDelay,
178		maxOpen:  maxOpen,
179		nextID:   1,
180		open:     avl.NewTree(),
181	}, nil
182}
183
184// MustNew is New but panics on error.
185func MustNew(minDelay, maxDelay int64, maxOpen int) *Book {
186	b, err := New(minDelay, maxDelay, maxOpen)
187	if err != nil {
188		panic(err)
189	}
190	return b
191}
192
193// MinDelay returns the Book's scheduling floor.
194func (b *Book) MinDelay() int64 { return b.minDelay }
195
196// MaxDelay returns the Book's scheduling ceiling.
197func (b *Book) MaxDelay() int64 { return b.maxDelay }
198
199// MaxOpen returns the Book's cap on simultaneously open deferrals.
200func (b *Book) MaxOpen() int { return b.maxOpen }
201
202// OpenCount returns how many deferrals are currently open.
203func (b *Book) OpenCount() int { return b.open.Size() }
204
205// NextID returns the identifier the next Schedule will allocate. It only
206// ever increases, which is what makes a consumed ID unreusable.
207func (b *Book) NextID() uint64 { return b.nextID }
208
209// Schedule opens a deferral owned by owner, due at now+delay, expiring
210// ttl after that (ttl == 0 means it never expires). It returns the new
211// deferral's ID.
212//
213// Fails with ErrEmptyOwner, ErrPayloadTooBig, ErrInvalidNow (now < 0),
214// ErrInvalidDelay (delay outside [MinDelay, MaxDelay]), ErrInvalidTTL
215// (ttl < 0), ErrBookFull, ErrOverflow, or ErrIDExhausted. On error
216// nothing is modified.
217func (b *Book) Schedule(owner, payload string, now, delay, ttl int64) (uint64, error) {
218	if owner == "" {
219		return 0, ErrEmptyOwner
220	}
221	if len(payload) > MaxPayloadLen {
222		return 0, ErrPayloadTooBig
223	}
224	if now < 0 {
225		return 0, ErrInvalidNow
226	}
227	if delay < b.minDelay || delay > b.maxDelay {
228		return 0, ErrInvalidDelay
229	}
230	if ttl < 0 {
231		return 0, ErrInvalidTTL
232	}
233	if b.open.Size() >= b.maxOpen {
234		return 0, ErrBookFull
235	}
236	if b.nextID == 0 {
237		return 0, ErrIDExhausted
238	}
239
240	dueAt, ok := checkedAdd(now, delay)
241	if !ok {
242		return 0, ErrOverflow
243	}
244	expiresAt := int64(0)
245	if ttl > 0 {
246		expiresAt, ok = checkedAdd(dueAt, ttl)
247		if !ok {
248			return 0, ErrOverflow
249		}
250	}
251
252	id := b.nextID
253	b.nextID++
254	b.open.Set(idKey(id), Deferral{
255		ID:        id,
256		Owner:     owner,
257		Payload:   payload,
258		CreatedAt: now,
259		DueAt:     dueAt,
260		ExpiresAt: expiresAt,
261	})
262	return id, nil
263}
264
265// MustSchedule is Schedule but panics on error.
266func (b *Book) MustSchedule(owner, payload string, now, delay, ttl int64) uint64 {
267	id, err := b.Schedule(owner, payload, now, delay, ttl)
268	if err != nil {
269		panic(err)
270	}
271	return id
272}
273
274// Claim consumes the deferral and returns it, authorizing the caller to
275// perform the deferred action NOW, in this transaction.
276//
277// It succeeds only while the deferral is open and now is in
278// [DueAt, ExpiresAt). The deferral is removed BEFORE Claim returns, so a
279// re-entrant or later Claim of the same ID finds nothing; combined with
280// non-reused IDs, at most one Claim per ID ever succeeds.
281//
282// Fails with ErrInvalidNow, ErrNotFound (never existed, or already
283// consumed by Claim/Cancel/Expire), ErrNotDue, or ErrExpired. On error
284// nothing is modified.
285//
286// duebook does NOT check who is claiming: whether a deferral is
287// permissionlessly claimable or restricted to its owner is the consuming
288// realm's policy, applied before calling Claim.
289func (b *Book) Claim(id uint64, now int64) (Deferral, error) {
290	if now < 0 {
291		return Deferral{}, ErrInvalidNow
292	}
293	d, ok := b.get(id)
294	if !ok {
295		return Deferral{}, ErrNotFound
296	}
297	if !d.IsDue(now) {
298		return Deferral{}, ErrNotDue
299	}
300	if d.IsExpired(now) {
301		return Deferral{}, ErrExpired
302	}
303	// Consume before returning: the caller acts only after this point, so
304	// the deferral is already gone when the effect runs.
305	b.open.Remove(idKey(id))
306	return d, nil
307}
308
309// MustClaim is Claim but panics on error.
310func (b *Book) MustClaim(id uint64, now int64) Deferral {
311	d, err := b.Claim(id, now)
312	if err != nil {
313		panic(err)
314	}
315	return d
316}
317
318// Cancel consumes an open deferral without performing it. Only its owner
319// may cancel, and cancellation is permitted at any time while the deferral
320// is open — including after it became due but before anyone claimed it.
321//
322// Fails with ErrEmptyOwner, ErrNotFound, or ErrNotOwner. On error nothing
323// is modified.
324func (b *Book) Cancel(id uint64, owner string) (Deferral, error) {
325	if owner == "" {
326		return Deferral{}, ErrEmptyOwner
327	}
328	d, ok := b.get(id)
329	if !ok {
330		return Deferral{}, ErrNotFound
331	}
332	if d.Owner != owner {
333		return Deferral{}, ErrNotOwner
334	}
335	b.open.Remove(idKey(id))
336	return d, nil
337}
338
339// MustCancel is Cancel but panics on error.
340func (b *Book) MustCancel(id uint64, owner string) Deferral {
341	d, err := b.Cancel(id, owner)
342	if err != nil {
343		panic(err)
344	}
345	return d
346}
347
348// Expire consumes a deferral that is past its expiry, reclaiming its
349// storage. It is deliberately permissionless: an expired deferral can
350// never be claimed again, so letting anyone clear it keeps a Book from
351// silting up with dead entries that block Schedule against maxOpen.
352//
353// Fails with ErrInvalidNow, ErrNotFound, or ErrNotExpired (including for
354// deferrals with no expiry, which never expire). On error nothing is
355// modified.
356func (b *Book) Expire(id uint64, now int64) (Deferral, error) {
357	if now < 0 {
358		return Deferral{}, ErrInvalidNow
359	}
360	d, ok := b.get(id)
361	if !ok {
362		return Deferral{}, ErrNotFound
363	}
364	if !d.IsExpired(now) {
365		return Deferral{}, ErrNotExpired
366	}
367	b.open.Remove(idKey(id))
368	return d, nil
369}
370
371// MustExpire is Expire but panics on error.
372func (b *Book) MustExpire(id uint64, now int64) Deferral {
373	d, err := b.Expire(id, now)
374	if err != nil {
375		panic(err)
376	}
377	return d
378}
379
380// Get returns an open deferral by ID. The second result is false if the
381// deferral never existed or has already been consumed — Get cannot tell
382// those apart, by design: consumed deferrals leave no tombstone.
383//
384// Get is a read-only preview and never authorizes anything. Only Claim's
385// return value authorizes an action.
386func (b *Book) Get(id uint64) (Deferral, bool) { return b.get(id) }
387
388// Due returns up to limit open deferrals that are claimable at now, in
389// ascending ID order (oldest first). A limit <= 0 returns nothing.
390//
391// It scans the open set, so its cost is bounded by MaxOpen.
392func (b *Book) Due(now int64, limit int) []Deferral {
393	out := []Deferral{}
394	if limit <= 0 || now < 0 {
395		return out
396	}
397	b.open.Iterate("", "", func(_ string, value any) bool {
398		d := value.(Deferral)
399		if d.IsClaimable(now) {
400			out = append(out, d)
401		}
402		return len(out) >= limit
403	})
404	return out
405}
406
407// Expirable returns up to limit open deferrals that Expire would accept at
408// now, in ascending ID order. A limit <= 0 returns nothing.
409func (b *Book) Expirable(now int64, limit int) []Deferral {
410	out := []Deferral{}
411	if limit <= 0 || now < 0 {
412		return out
413	}
414	b.open.Iterate("", "", func(_ string, value any) bool {
415		d := value.(Deferral)
416		if d.IsExpired(now) {
417			out = append(out, d)
418		}
419		return len(out) >= limit
420	})
421	return out
422}
423
424// IterateOpen calls fn for every open deferral in ascending ID order.
425// Iteration stops early when fn returns true.
426//
427// fn receives a COPY: Deferral is passed by value and holds only scalars
428// and strings, so fn gets no pointer into the Book and cannot reach past
429// it — Book's fields are all unexported.
430//
431// Two rules for fn, and the second is the one that is easy to miss.
432//
433//   - Do not Schedule, Claim, Cancel or Expire from inside fn: mutating
434//     the tree while iterating it is undefined. Collect IDs first, then
435//     act after IterateOpen returns.
436//
437//   - fn runs under the CALLING REALM'S STORAGE AUTHORITY. This method's
438//     receiver is stamped with the importing realm's PkgID, so the borrow
439//     rules leave the realm context set to that realm for the whole
440//     callback, and a top-level fn has no receiver and no declaring realm
441//     to anchor it elsewhere. A callback that re-enters the calling
442//     realm's own mutators therefore does so with that realm's authority.
443//     Never pass a caller-supplied function here from inside a
444//     permission-gated path; pass only a closure this package's consumer
445//     wrote itself.
446func (b *Book) IterateOpen(fn func(Deferral) bool) {
447	b.open.Iterate("", "", func(_ string, value any) bool {
448		return fn(value.(Deferral))
449	})
450}
451
452// get looks up an open deferral without touching the tree otherwise.
453func (b *Book) get(id uint64) (Deferral, bool) {
454	v := b.open.Get(idKey(id))
455	if v == nil {
456		return Deferral{}, false
457	}
458	return v.(Deferral), true
459}
460
461// idKey encodes an ID so that avl's lexical key order matches numeric ID
462// order. math.MaxUint64 is 20 digits, so a fixed 20-wide zero-padded
463// decimal is both sufficient and unambiguous.
464func idKey(id uint64) string {
465	s := strconv.FormatUint(id, 10)
466	const width = 20
467	if len(s) >= width {
468		return s
469	}
470	return zeros[:width-len(s)] + s
471}
472
473const zeros = "00000000000000000000"
474
475// checkedAdd returns a+b and reports whether the addition did not
476// overflow int64.
477func checkedAdd(a, b int64) (int64, bool) {
478	sum := a + b
479	if (b > 0 && sum < a) || (b < 0 && sum > a) {
480		return 0, false
481	}
482	return sum, true
483}