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.

spendbook.gno

24.44 Kb · 687 lines
  1// Package spendbook is a pure allowance primitive for realms that need to
  2// let one party spend another party's funds, up to a bounded amount, over
  3// many transactions. It is to *delegated spending* what duebook is to
  4// *time*: it holds no coins, performs no effects, imports no chain APIs,
  5// and owns no package-level state. The importing realm owns a *Book,
  6// supplies the clock, and performs its own effects.
  7//
  8// # Why this package exists
  9//
 10// The ecosystem's existing allowance primitive,
 11// gno.land/p/jaekwon/allowancesender/v0, is scoped to a single call and
 12// says so in its own documentation:
 13//
 14//	"Allowance survives across N txs but not N+1 txs." Use a session
 15//	counter pattern in the granter realm; this package's Close is
 16//	binary, not deadline-based.
 17//
 18// It then declines block-height expiry on the grounds that it "would
 19// require runtime cooperation". That is true only for a package that reads
 20// the clock itself. spendbook does not: every operation takes `now` as an
 21// argument, which makes the window arithmetic a pure function and the
 22// whole package unit-testable without a chain. Three application realms in
 23// the searched sources each hand-rolled this mechanism internally rather
 24// than importing one; see DISCOVERY.md.
 25//
 26// # The one idea
 27//
 28// An allowance is not a balance. It is a *permission with a ceiling that
 29// refills*. The owner says "this spender may draw at most PerPeriod per
 30// window, at most Total ever, until ExpiresAt, and I can revoke it now":
 31//
 32//	if err := book.Spend(id, spenderKey, now, amount); err != nil {
 33//		panic(err) // revoked, expired, over the window cap, over the total
 34//	}
 35//	// ... the realm moves the coins here, under its own authority
 36//
 37// Spend checks every bound and CONSUMES the allowance in the same call,
 38// before returning. The realm then acts. Because the realm performs the
 39// effect itself, no closure, callback, or capability ever crosses a realm
 40// boundary. spendbook cannot be handed code to run, so it cannot be
 41// tricked into running the wrong code.
 42//
 43// # Windows are anchored, not sliding
 44//
 45// A window is derived from a fixed origin — the Anchor set at Open — by
 46// integer division:
 47//
 48//	index(now) = (now - Anchor) / PeriodLength
 49//
 50// so the boundaries are the same for everyone and a spender cannot reset a
 51// window by choosing when to call. A sliding "PerPeriod per trailing N
 52// blocks" window would let a spender draw the full cap at the end of one
 53// trailing span and again at the start of the next; anchoring removes the
 54// choice. The refill is a RESET, not a carry: an unused allowance does not
 55// accumulate into the next window, because the point of the cap is to bound
 56// worst-case loss per window, and a carry would unbound it.
 57//
 58// PeriodLength == 0 means one window that never rolls: a pure lifetime
 59// allowance, where PerPeriod is the only cap that matters.
 60//
 61// # Amendments cannot enlarge the live window
 62//
 63// An owner who could raise PerPeriod mid-window would be able to undo the
 64// bound a spender is relying on, and — worse in the other direction — an
 65// owner who drains a relationship and then lowers terms retroactively
 66// could strand a spender mid-settlement. The rule here is one-sided and
 67// enforced in Amend:
 68//
 69//   - TIGHTER-OR-EQUAL terms apply IMMEDIATELY. Lowering a ceiling can
 70//     never authorize a draw that was not already authorized, so there is
 71//     no reason to delay it, and delaying it would make the owner's only
 72//     fast remedy Revoke.
 73//   - LOOSER terms are PENDING and take effect at the next window
 74//     boundary, never inside the current one.
 75//
 76// This is the same adversarial problem that r/samcrew/agent_registry_v2
 77// solves by locking its price while any credit entry exists; the shape
 78// here is a deferral rather than a lock, so terms can still move.
 79//
 80// With PeriodLength == 0 there is no next boundary, so a loosening
 81// amendment is refused with ErrNoBoundary rather than silently queued
 82// forever. PeriodLength itself is IMMUTABLE: changing it would move every
 83// future boundary, which is an enlargement vector wearing a different hat.
 84//
 85// # State growth
 86//
 87// Revoked allowances are removed, not archived. IDs are allocated
 88// monotonically and never reused, so a revoked ID can never be resurrected
 89// and there is nothing to keep. Live allowances are capped per Book at
 90// construction, so storage is bounded by maxLive regardless of how many
 91// allowances have ever existed. Audit history belongs in the consuming
 92// realm's events.
 93//
 94// An EXPIRED allowance is not self-removing, and bounded storage is not the
 95// same as a usable Book: Spend refuses it forever while it still holds a
 96// slot. Reap exists for exactly that and is permissionless, because an
 97// expired allowance can authorize nothing and so removing it takes nothing
 98// from anyone.
 99//
100// What this package does NOT bound is how many of a Book's slots ONE owner
101// may hold — maxLive is a single global figure, so any owner permitted to
102// Open can exhaust it and lock out the rest. That is a policy question with
103// no answer a primitive can pick (per-owner quota? first-come? owner
104// whitelist?), so it is left where the authentication already lives: the
105// consuming realm decides who may Open, and a Book shared across mutually
106// untrusting owners needs its own per-owner cap on top of this one. Named
107// here rather than implied, because "bounded" reads like "safe to share".
108//
109// # Consumer contract (the parts the package cannot enforce)
110//
111//  1. SUPPLY A REAL CLOCK. spendbook cannot verify that `now` came from
112//     runtime.ChainHeight() or time.Now().Unix(). A realm that lets a
113//     caller choose `now` has handed the spender the power to roll the
114//     window at will, which defeats every cap in this package. Pass the
115//     chain's own clock, and use the same units for the life of the Book.
116//
117//  2. AUTHENTICATE THE SPENDER KEY. spendbook compares the key string it
118//     is given against the one recorded at Open. It cannot tell whether
119//     that string is the real caller. Derive it from
120//     cur.Previous().Address(), never from an argument the caller supplies.
121//
122//  3. SPEND BEFORE SENDING. Spend consumes the allowance and returns; the
123//     realm then moves coins. Doing it in the other order means a failed
124//     send leaves the allowance consumed, or a successful send leaves it
125//     unconsumed — both are real losses. State first, effect second.
126//
127//  4. AN ALLOWANCE IS NOT CUSTODY. spendbook authorizing 100 does not mean
128//     100 exists. The realm must check its own held balance separately;
129//     this package bounds permission, not solvency.
130package spendbook
131
132import (
133	"errors"
134	"strconv"
135
136	"gno.land/p/nt/avl/v0"
137)
138
139// MaxLiveLimit is the largest maxLive a Book may be constructed with. It
140// bounds the worst-case cost of Iterate, which scans the live set.
141const MaxLiveLimit = 10000
142
143// MaxKeyLen bounds an owner or spender key. A bech32 gno address is 40
144// characters; the headroom exists so a realm may namespace its keys.
145const MaxKeyLen = 128
146
147// maxInt64 is the overflow boundary for every accumulation here.
148const maxInt64 = int64(1<<63 - 1)
149
150// Errors returned by Book operations.
151var (
152	ErrInvalidConfig  = errors.New("spendbook: invalid book configuration")
153	ErrEmptyOwner     = errors.New("spendbook: empty owner key")
154	ErrEmptySpender   = errors.New("spendbook: empty spender key")
155	ErrKeyTooLong     = errors.New("spendbook: key exceeds MaxKeyLen")
156	ErrInvalidNow     = errors.New("spendbook: now must be non-negative")
157	ErrInvalidTerms   = errors.New("spendbook: invalid terms")
158	ErrInvalidAmount  = errors.New("spendbook: amount must be positive")
159	ErrBookFull       = errors.New("spendbook: live allowance cap reached")
160	ErrNotFound       = errors.New("spendbook: no such live allowance")
161	ErrNotExpired     = errors.New("spendbook: allowance has not expired")
162	ErrNotOwner       = errors.New("spendbook: caller does not own this allowance")
163	ErrNotSpender     = errors.New("spendbook: caller is not this allowance's spender")
164	ErrExpired        = errors.New("spendbook: allowance has expired")
165	ErrNotStarted     = errors.New("spendbook: allowance has not started")
166	ErrPeriodExceeded = errors.New("spendbook: amount exceeds the window cap")
167	ErrTotalExceeded  = errors.New("spendbook: amount exceeds the lifetime cap")
168	ErrNoBoundary     = errors.New("spendbook: no reachable next window boundary")
169	ErrOverflow       = errors.New("spendbook: int64 overflow")
170	ErrIDExhausted    = errors.New("spendbook: identifier space exhausted")
171)
172
173// Terms are the ceilings on an allowance. Zero has a distinct meaning in
174// three of the four fields and it is never "zero allowed":
175//
176//	PerPeriod    > 0 required. The cap per window.
177//	PeriodLength 0 means ONE window that never rolls (lifetime).
178//	Total        0 means no lifetime cap.
179//	ExpiresAt    0 means no expiry.
180type Terms struct {
181	PerPeriod    int64
182	PeriodLength int64
183	Total        int64
184	ExpiresAt    int64
185}
186
187// valid reports whether the terms are self-consistent, given the anchor the
188// allowance is (or would be) opened at.
189func (t Terms) valid(anchor int64) bool {
190	if t.PerPeriod <= 0 || t.PeriodLength < 0 || t.Total < 0 {
191		return false
192	}
193	if t.ExpiresAt < 0 {
194		return false
195	}
196	// An allowance that has already expired at its own anchor can never
197	// authorize anything, so accepting it would only create a live entry
198	// that every Spend refuses.
199	if t.ExpiresAt != 0 && t.ExpiresAt <= anchor {
200		return false
201	}
202	// A Total below PerPeriod is not an error -- it is a narrower lifetime
203	// cap, which Spend enforces. A Total of exactly 0 is "unlimited".
204	return true
205}
206
207// tighterOrEqualTo reports whether t is weakly tighter than u in every
208// dimension, i.e. whether t can authorize nothing u would not have. This is
209// the predicate that decides whether an amendment applies immediately.
210//
211// The zero-means-unlimited fields invert the comparison, which is the whole
212// subtlety: Total 0 is LOOSER than any positive Total, and ExpiresAt 0 is
213// LOOSER than any positive ExpiresAt. Reading those two as "smaller is
214// tighter" would let an owner remove a lifetime cap and an expiry
215// mid-window and call it a tightening.
216func (t Terms) tighterOrEqualTo(u Terms) bool {
217	if t.PeriodLength != u.PeriodLength {
218		return false // immutable; see the package doc
219	}
220	if t.PerPeriod > u.PerPeriod {
221		return false
222	}
223	switch {
224	case u.Total == 0:
225		// u was unlimited; anything is tighter or equal.
226	case t.Total == 0 || t.Total > u.Total:
227		return false
228	}
229	switch {
230	case u.ExpiresAt == 0:
231		// u never expired; any expiry is tighter or equal.
232	case t.ExpiresAt == 0 || t.ExpiresAt > u.ExpiresAt:
233		return false
234	}
235	return true
236}
237
238// Allowance is one owner's grant to one spender. It is returned BY VALUE:
239// holders cannot reach into a Book through it.
240type Allowance struct {
241	ID        uint64
242	Owner     string
243	Spender   string
244	Terms     Terms
245	Anchor    int64 // window origin; also the earliest spendable instant
246	CreatedAt int64
247
248	SpentTotal  int64 // over the allowance's whole life
249	SpentWindow int64 // within WindowIndex
250	WindowIndex int64
251
252	// Pending terms, applied at the next window boundary. Meaningful only
253	// when HasPending is true.
254	Pending    Terms
255	HasPending bool
256}
257
258// IsExpired reports whether the allowance is past its expiry at now. An
259// allowance with no expiry is never expired.
260func (a Allowance) IsExpired(now int64) bool {
261	return a.Terms.ExpiresAt != 0 && now >= a.Terms.ExpiresAt
262}
263
264// windowIndex returns the anchored window number containing now. A lifetime
265// allowance (PeriodLength == 0) is always in window 0.
266//
267// now < Anchor cannot reach here: every caller rejects it first with
268// ErrNotStarted, which keeps this division away from negative numerators
269// where Go's truncation-toward-zero would fold two windows into one.
270func (a Allowance) windowIndex(now int64) int64 {
271	if a.Terms.PeriodLength == 0 {
272		return 0
273	}
274	return (now - a.Anchor) / a.Terms.PeriodLength
275}
276
277// nextBoundary returns the instant window idx+1 begins, and whether that
278// instant is reachable at all.
279//
280// Unreachable has three causes and they collapse to the same answer, which is
281// why this returns a bool rather than an error: a lifetime allowance has no
282// boundary by construction, and an arithmetically unrepresentable boundary
283// (PeriodLength so large, or the clock so far along, that Anchor + steps *
284// PeriodLength overflows int64) is one no clock will ever reach. Computing it
285// unguarded would wrap to a small number and report a boundary in the PAST,
286// which is the dangerous direction: Amend would queue a loosening as though
287// it were about to take effect.
288func (a Allowance) nextBoundary(idx int64) (int64, bool) {
289	pl := a.Terms.PeriodLength
290	if pl == 0 || idx < 0 || idx == maxInt64 {
291		return 0, false
292	}
293	steps := idx + 1
294	if steps > maxInt64/pl {
295		return 0, false
296	}
297	off := steps * pl
298	if off > maxInt64-a.Anchor {
299		return 0, false
300	}
301	return a.Anchor + off, true
302}
303
304// at computes the allowance's effective state at now WITHOUT mutating it:
305// the terms in force, the window it falls in, and how much has been spent
306// within that window.
307//
308// Spend and Remaining BOTH go through this one function. That is deliberate
309// -- a read that answered "how much is left" with its own copy of the
310// rollover and amendment logic would be a second implementation of the
311// rule, free to disagree with the one that enforces it, and the
312// disagreement would surface as an allowance that reports more than it
313// honors (or honors more than it reports).
314func (a Allowance) at(now int64) (Terms, int64, int64) {
315	idx := a.windowIndex(now)
316	if idx == a.WindowIndex {
317		return a.Terms, idx, a.SpentWindow
318	}
319	// The window has rolled. A pending amendment takes effect exactly here,
320	// and the in-window spend resets -- it does not carry.
321	terms := a.Terms
322	if a.HasPending {
323		terms = a.Pending
324	}
325	return terms, idx, 0
326}
327
328// Remaining reports how much the spender may draw right now: the lesser of
329// what the current window leaves and what the lifetime cap leaves. It is 0
330// for an allowance that is expired, not yet started, or fully drawn.
331//
332// It is a projection, not a reservation. Nothing is held, and the figure can
333// change in the same block if someone else's call advances the clock past a
334// boundary.
335func (a Allowance) Remaining(now int64) int64 {
336	if now < a.Anchor || a.IsExpired(now) {
337		return 0
338	}
339	terms, _, spentWindow := a.at(now)
340	left := terms.PerPeriod - spentWindow
341	if left < 0 {
342		// Reachable: a tightening amendment can drop PerPeriod below what
343		// the window already spent. The allowance is drawn, not negative.
344		left = 0
345	}
346	if terms.Total != 0 {
347		lifeLeft := terms.Total - a.SpentTotal
348		if lifeLeft < 0 {
349			lifeLeft = 0
350		}
351		if lifeLeft < left {
352			left = lifeLeft
353		}
354	}
355	return left
356}
357
358// Book holds the live allowances of one consuming realm. The zero value is
359// not usable; construct with New.
360type Book struct {
361	maxLive int
362	nextID  uint64    // never decreases; IDs are never reused
363	live    *avl.Tree // padded id -> Allowance
364}
365
366// New returns an empty Book. maxLive caps simultaneously live allowances
367// and must be in [1, MaxLiveLimit].
368func New(maxLive int) (*Book, error) {
369	if maxLive < 1 || maxLive > MaxLiveLimit {
370		return nil, ErrInvalidConfig
371	}
372	return &Book{maxLive: maxLive, nextID: 1, live: avl.NewTree()}, nil
373}
374
375// MustNew is New but panics on error.
376func MustNew(maxLive int) *Book {
377	b, err := New(maxLive)
378	if err != nil {
379		panic(err)
380	}
381	return b
382}
383
384// MaxLive returns the Book's cap on simultaneously live allowances.
385func (b *Book) MaxLive() int { return b.maxLive }
386
387// Count returns the number of live allowances.
388func (b *Book) Count() int { return b.live.Size() }
389
390// key pads an id to a fixed width so avl's lexical order matches numeric
391// order. 20 digits covers the whole uint64 range.
392func key(id uint64) string {
393	s := strconv.FormatUint(id, 10)
394	for len(s) < 20 {
395		s = "0" + s
396	}
397	return s
398}
399
400func checkKeys(owner, spender string) error {
401	if owner == "" {
402		return ErrEmptyOwner
403	}
404	if spender == "" {
405		return ErrEmptySpender
406	}
407	if len(owner) > MaxKeyLen || len(spender) > MaxKeyLen {
408		return ErrKeyTooLong
409	}
410	return nil
411}
412
413// Open records a new allowance and returns its id. now becomes the window
414// anchor and the earliest instant the spender may draw.
415//
416// An owner may open several allowances for the same spender; they are
417// independent, and the ids are what tell them apart. spendbook does not
418// merge or deduplicate them, because "one allowance per pair" is a policy
419// some consuming realms want and others do not, and a primitive that
420// decided it here could not be talked out of it.
421func (b *Book) Open(owner, spender string, now int64, terms Terms) (uint64, error) {
422	if err := checkKeys(owner, spender); err != nil {
423		return 0, err
424	}
425	if now < 0 {
426		return 0, ErrInvalidNow
427	}
428	if !terms.valid(now) {
429		return 0, ErrInvalidTerms
430	}
431	if b.live.Size() >= b.maxLive {
432		return 0, ErrBookFull
433	}
434	if b.nextID == 0 {
435		return 0, ErrIDExhausted
436	}
437	id := b.nextID
438	b.nextID++
439	b.live.Set(key(id), Allowance{
440		ID:        id,
441		Owner:     owner,
442		Spender:   spender,
443		Terms:     terms,
444		Anchor:    now,
445		CreatedAt: now,
446	})
447	return id, nil
448}
449
450// get fetches a live allowance by id.
451//
452// avl's Get returns a bare `any` with no found flag, so absence is a nil
453// value. That is sound here only because nothing nil is ever stored: Set is
454// called in exactly three places and all three pass an Allowance value.
455func (b *Book) get(id uint64) (Allowance, bool) {
456	v := b.live.Get(key(id))
457	if v == nil {
458		return Allowance{}, false
459	}
460	a, ok := v.(Allowance)
461	if !ok {
462		return Allowance{}, false
463	}
464	return a, true
465}
466
467// Get returns a copy of a live allowance.
468func (b *Book) Get(id uint64) (Allowance, error) {
469	a, ok := b.get(id)
470	if !ok {
471		return Allowance{}, ErrNotFound
472	}
473	return a, nil
474}
475
476// Remaining reports what the spender of id may draw at now, or 0 if there
477// is no such live allowance. A caller that must distinguish "nothing left"
478// from "no such allowance" should use Get.
479func (b *Book) Remaining(id uint64, now int64) int64 {
480	a, ok := b.get(id)
481	if !ok {
482		return 0
483	}
484	return a.Remaining(now)
485}
486
487// Spend consumes amount from allowance id on behalf of spender and returns
488// nil if every bound permits it. The caller then performs its own effect.
489//
490// On any error NOTHING is consumed. The write happens once, after the last
491// check, so a refused Spend leaves the book exactly as it was.
492func (b *Book) Spend(id uint64, spender string, now int64, amount int64) error {
493	if now < 0 {
494		return ErrInvalidNow
495	}
496	if amount <= 0 {
497		return ErrInvalidAmount
498	}
499	a, ok := b.get(id)
500	if !ok {
501		return ErrNotFound
502	}
503	if a.Spender != spender {
504		return ErrNotSpender
505	}
506	if now < a.Anchor {
507		return ErrNotStarted
508	}
509	if a.IsExpired(now) {
510		return ErrExpired
511	}
512
513	terms, idx, spentWindow := a.at(now)
514
515	// Window cap. Checked as a subtraction so the sum cannot overflow:
516	// spentWindow and PerPeriod are both >= 0 and PerPeriod > 0.
517	if amount > terms.PerPeriod-spentWindow {
518		return ErrPeriodExceeded
519	}
520	// Lifetime cap.
521	if terms.Total != 0 && amount > terms.Total-a.SpentTotal {
522		return ErrTotalExceeded
523	}
524	// SpentTotal is unbounded when Total == 0, so it is the one
525	// accumulation that can genuinely reach the int64 ceiling.
526	if a.SpentTotal > maxInt64-amount {
527		return ErrOverflow
528	}
529
530	a.Terms = terms
531	if idx != a.WindowIndex {
532		a.HasPending = false
533		a.Pending = Terms{}
534	}
535	a.WindowIndex = idx
536	a.SpentWindow = spentWindow + amount
537	a.SpentTotal += amount
538	b.live.Set(key(id), a)
539	return nil
540}
541
542// Revoke removes an allowance. Only its owner may revoke, and it takes
543// effect immediately -- there is no window to wait for, because revocation
544// is the owner's remedy and a delayed remedy is not one.
545//
546// The id is not reused, so a revoked allowance cannot be resurrected: a
547// later Spend on it returns ErrNotFound forever.
548func (b *Book) Revoke(id uint64, owner string) error {
549	a, ok := b.get(id)
550	if !ok {
551		return ErrNotFound
552	}
553	if a.Owner != owner {
554		return ErrNotOwner
555	}
556	b.live.Remove(key(id))
557	return nil
558}
559
560// Amend changes an allowance's terms. Only its owner may amend.
561//
562// PeriodLength is immutable and must be passed unchanged. Terms that are
563// weakly tighter in every dimension apply immediately; anything looser
564// becomes pending and takes effect at the next window boundary. A lifetime
565// allowance has no next boundary, so a loosening amendment on one is
566// refused with ErrNoBoundary.
567//
568// The returned bool reports whether the amendment applied immediately
569// (true) or was queued (false). A caller that ignores it cannot tell
570// whether the new ceiling is in force, which is exactly the thing worth
571// knowing.
572func (b *Book) Amend(id uint64, owner string, now int64, terms Terms) (bool, error) {
573	if now < 0 {
574		return false, ErrInvalidNow
575	}
576	a, ok := b.get(id)
577	if !ok {
578		return false, ErrNotFound
579	}
580	if a.Owner != owner {
581		return false, ErrNotOwner
582	}
583	if now < a.Anchor {
584		return false, ErrNotStarted
585	}
586	if a.IsExpired(now) {
587		return false, ErrExpired
588	}
589	// Validated against the ANCHOR rather than now, which is the LOOSER of
590	// the two and is deliberate. valid() only needs ExpiresAt to be after the
591	// window origin; an owner naming an expiry between the anchor and now is
592	// tightening the allowance until it is dead, which is their prerogative.
593	// Revoke is the cleaner way to say that, and Reap then reclaims the slot.
594	if !terms.valid(a.Anchor) {
595		return false, ErrInvalidTerms
596	}
597	if terms.PeriodLength != a.Terms.PeriodLength {
598		return false, ErrInvalidTerms
599	}
600
601	// Roll the window first if the clock has moved past a boundary, so an
602	// amendment made after a quiet stretch is judged against the terms
603	// actually in force rather than the ones a stale record still holds.
604	cur, idx, spentWindow := a.at(now)
605	if idx != a.WindowIndex {
606		a.Terms = cur
607		a.WindowIndex = idx
608		a.SpentWindow = spentWindow
609		a.HasPending = false
610		a.Pending = Terms{}
611	}
612
613	if terms.tighterOrEqualTo(a.Terms) {
614		a.Terms = terms
615		// A tightening supersedes a queued loosening. Leaving it queued
616		// would mean the owner's tightening silently expires at the next
617		// boundary and the looser terms they just replaced come back.
618		a.HasPending = false
619		a.Pending = Terms{}
620		b.live.Set(key(id), a)
621		return true, nil
622	}
623
624	// A loosening is only queueable if the boundary it waits for will arrive
625	// while the allowance is still alive. Queueing one that cannot be reached
626	// would report "accepted, pending" for terms that never take effect --
627	// the owner's amendment silently discarded, which is the failure mode
628	// this package is least able to explain after the fact.
629	//
630	// Two ways it is unreachable. The allowance has no boundary at all
631	// (PeriodLength == 0, or an unrepresentable one), or the current terms
632	// expire at or before the next boundary. The second is reachable in
633	// ordinary use: a tightening of ExpiresAt applies IMMEDIATELY, so an
634	// owner can tighten the expiry below the next boundary and then loosen
635	// PerPeriod, and without this check the second call would be accepted
636	// against a window that never opens.
637	nb, ok := a.nextBoundary(a.WindowIndex)
638	if !ok {
639		return false, ErrNoBoundary
640	}
641	if a.Terms.ExpiresAt != 0 && nb >= a.Terms.ExpiresAt {
642		return false, ErrNoBoundary
643	}
644	a.Pending = terms
645	a.HasPending = true
646	b.live.Set(key(id), a)
647	return false, nil
648}
649
650// Reap removes an allowance that has expired at now, and may be called by
651// anyone. It returns ErrNotFound for an unknown id and ErrNotExpired for one
652// that is still alive.
653//
654// Permissionless on purpose, and the reasoning is the whole justification:
655// an expired allowance can never authorize another draw, so removing it takes
656// nothing from the owner and grants nothing to the caller. Without it an
657// expiry is a slow leak against maxLive -- Spend refuses the dead allowance
658// forever while it holds a slot, so a Book shared between owners could fill
659// with corpses and refuse new Opens, and the only remedy would be the owner
660// of each corpse noticing and calling Revoke.
661//
662// This relies on `now` exactly as much as Spend does. A realm that lets a
663// caller choose `now` has already broken consumer contract item 1, and such a
664// realm would let a caller reap a LIVE allowance by naming a future instant.
665func (b *Book) Reap(id uint64, now int64) error {
666	if now < 0 {
667		return ErrInvalidNow
668	}
669	a, ok := b.get(id)
670	if !ok {
671		return ErrNotFound
672	}
673	if !a.IsExpired(now) {
674		return ErrNotExpired
675	}
676	b.live.Remove(key(id))
677	return nil
678}
679
680// Iterate calls fn for every live allowance in id order, stopping early if
681// fn returns true. The allowance is passed by value, so fn cannot mutate
682// the book through it.
683func (b *Book) Iterate(fn func(Allowance) bool) {
684	b.live.Iterate("", "", func(_ string, v any) bool {
685		return fn(v.(Allowance))
686	})
687}