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

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}