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