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}