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.

v2 source pure

Package spendbook is a pure allowance primitive for realms that need to let one party spend another party's funds, up...

Overview

Package spendbook is a pure allowance primitive for realms that need to let one party spend another party's funds, up to a bounded amount, over many transactions. It is to *delegated spending* what duebook is to *time*: it holds no coins, performs no effects, imports no chain APIs, and owns no package-level state. The importing realm owns a *Book, supplies the clock, and performs its own effects.

Why v2 exists

v1 of this package is deployed and immutable. Its API is unchanged here; ONE method body differs, and it is a correctness fix:

Example
1v1:  func (a Allowance) IsExpired(now int64) bool {
2         return a.Terms.ExpiresAt != 0 && now >= a.Terms.ExpiresAt
3     }

a.Terms is the LAST-WRITTEN record, not the terms in force. Every other live question in this package is routed through at(), which resolves a pending amendment at the window boundary it is due at; IsExpired was the one exported predicate that read the stored field instead. Because an amendment that moves ExpiresAt in the LOOSER direction can only ever be QUEUED -- tighterOrEqualTo refuses both "push the expiry out" and "remove the expiry" as immediate -- queueing is the only path such an amendment has, so the stale read was on that path every time.

The consequence is not one bug but two, pointing opposite ways, and the second is the dangerous one:

  • UNDER-authorization. An owner extends the expiry. The amendment is accepted and queued, the boundary arrives, at() puts the new expiry in force -- and IsExpired still answers from the old one. At the OLD expiry: Remaining reports 0, Spend returns ErrExpired, and Reap, which is PERMISSIONLESS, lets any third party delete an allowance the owner had just extended. The owner's accepted amendment is silently discarded, which the v1 doc calls "the failure mode this package is least able to explain after the fact" -- about a different case, in Amend, four lines from the guard that does not cover this one.

  • OVER-authorization. An amendment may be looser in PerPeriod and TIGHTER in ExpiresAt; tighterOrEqualTo is weakly-tighter in EVERY dimension, so that mix queues as a whole. Past the boundary, at() hands Spend the new PerPeriod while IsExpired reports the allowance alive on the OLD, later expiry. The spender draws -- at the raised cap -- past an expiry the owner themselves set. No bound in this package stops it, because the bound was moved and the gate was not told.

Both are repaired by resolving the expiry through at(). Neither could be repaired in v1: a /p/ is immutable after addpkg, and v1's two live consumers are themselves immutable realms with v1's bytes compiled into every path they run. v1 therefore stays live and keeps the defect; it is not retired by this package existing, and nothing here migrates it.

The v1 workaround, for a consumer stuck on it: restating the SAME amendment inside [next boundary, old expiry) applies it immediately -- Amend rolls the window first, promoting the pending terms into a.Terms, and the restatement is then equal-to rather than looser. It is owner-only, moves no funds, and needs no spend. It must be done BEFORE the old expiry; afterwards v1's Amend refuses with ErrExpired, and Revoke plus a fresh Open is the only route left.

Why this package exists

The ecosystem's existing allowance primitive, gno.land/p/jaekwon/allowancesender/v0, is scoped to a single call and says so in its own documentation:

Example
1"Allowance survives across N txs but not N+1 txs." Use a session
2counter pattern in the granter realm; this package's Close is
3binary, not deadline-based.

It then declines block-height expiry on the grounds that it "would require runtime cooperation". That is true only for a package that reads the clock itself. spendbook does not: every operation takes `now` as an argument, which makes the window arithmetic a pure function and the whole package unit-testable without a chain. Three application realms in the searched sources each hand-rolled this mechanism internally rather than importing one; see DISCOVERY.md.

The one idea

An allowance is not a balance. It is a *permission with a ceiling that refills*. The owner says "this spender may draw at most PerPeriod per window, at most Total ever, until ExpiresAt, and I can revoke it now":

Example
1if err := book.Spend(id, spenderKey, now, amount); err != nil {
2	panic(err) // revoked, expired, over the window cap, over the total
3}
4// ... the realm moves the coins here, under its own authority

Spend checks every bound and CONSUMES the allowance in the same call, before returning. The realm then acts. Because the realm performs the effect itself, no closure, callback, or capability ever crosses a realm boundary. spendbook cannot be handed code to run, so it cannot be tricked into running the wrong code.

Windows are anchored, not sliding

A window is derived from a fixed origin — the Anchor set at Open — by integer division:

Example
1index(now) = (now - Anchor) / PeriodLength

so the boundaries are the same for everyone and a spender cannot reset a window by choosing when to call. A sliding "PerPeriod per trailing N blocks" window would let a spender draw the full cap at the end of one trailing span and again at the start of the next; anchoring removes the choice. The refill is a RESET, not a carry: an unused allowance does not accumulate into the next window, because the point of the cap is to bound worst-case loss per window, and a carry would unbound it.

PeriodLength == 0 means one window that never rolls: a pure lifetime allowance, where PerPeriod is the only cap that matters.

Amendments cannot enlarge the live window

An owner who could raise PerPeriod mid-window would be able to undo the bound a spender is relying on, and — worse in the other direction — an owner who drains a relationship and then lowers terms retroactively could strand a spender mid-settlement. The rule here is one-sided and enforced in Amend:

  • TIGHTER-OR-EQUAL terms apply IMMEDIATELY. Lowering a ceiling can never authorize a draw that was not already authorized, so there is no reason to delay it, and delaying it would make the owner's only fast remedy Revoke.
  • LOOSER terms are PENDING and take effect at the next window boundary, never inside the current one.

This is the same adversarial problem that r/samcrew/agent_registry_v2 solves by locking its price while any credit entry exists; the shape here is a deferral rather than a lock, so terms can still move.

With PeriodLength == 0 there is no next boundary, so a loosening amendment is refused with ErrNoBoundary rather than silently queued forever. PeriodLength itself is IMMUTABLE: changing it would move every future boundary, which is an enlargement vector wearing a different hat.

State growth

Revoked allowances are removed, not archived. IDs are allocated monotonically and never reused, so a revoked ID can never be resurrected and there is nothing to keep. Live allowances are capped per Book at construction, so storage is bounded by maxLive regardless of how many allowances have ever existed. Audit history belongs in the consuming realm's events.

An EXPIRED allowance is not self-removing, and bounded storage is not the same as a usable Book: Spend refuses it forever while it still holds a slot. Reap exists for exactly that and is permissionless, because an expired allowance can authorize nothing and so removing it takes nothing from anyone.

What this package does NOT bound is how many of a Book's slots ONE owner may hold — maxLive is a single global figure, so any owner permitted to Open can exhaust it and lock out the rest. That is a policy question with no answer a primitive can pick (per-owner quota? first-come? owner whitelist?), so it is left where the authentication already lives: the consuming realm decides who may Open, and a Book shared across mutually untrusting owners needs its own per-owner cap on top of this one. Named here rather than implied, because "bounded" reads like "safe to share".

Consumer contract (the parts the package cannot enforce)

  1. SUPPLY A REAL CLOCK. spendbook cannot verify that `now` came from runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller choose `now` has handed the spender the power to roll the window at will, which defeats every cap in this package. Pass the chain's own clock, and use the same units for the life of the Book.

  2. AUTHENTICATE THE SPENDER KEY. spendbook compares the key string it is given against the one recorded at Open. It cannot tell whether that string is the real caller. Derive it from cur.Previous().Address(), never from an argument the caller supplies.

  3. SPEND BEFORE SENDING. Spend consumes the allowance and returns; the realm then moves coins. Doing it in the other order means a failed send leaves the allowance consumed, or a successful send leaves it unconsumed — both are real losses. State first, effect second.

  4. AN ALLOWANCE IS NOT CUSTODY. spendbook authorizing 100 does not mean 100 exists. The realm must check its own held balance separately; this package bounds permission, not solvency.

Constants 2

const MaxKeyLen

1const MaxKeyLen = 128
source

MaxKeyLen bounds an owner or spender key. A bech32 gno address is 40 characters; the headroom exists so a realm may namespace its keys.

const MaxLiveLimit

1const MaxLiveLimit = 10000
source

MaxLiveLimit is the largest maxLive a Book may be constructed with. It bounds the worst-case cost of Iterate, which scans the live set.

Variables 1

var ErrInvalidConfig, ErrEmptyOwner, ErrEmptySpender, ErrKeyTooLong, ErrInvalidNow, ErrInvalidTerms, ErrInvalidAmount, ErrBookFull, ErrNotFound, ErrNotExpired, ErrNotOwner, ErrNotSpender, ErrExpired, ErrNotStarted, ErrPeriodExceeded, ErrTotalExceeded, ErrNoBoundary, ErrOverflow, ErrIDExhausted

 1var (
 2	ErrInvalidConfig  = errors.New("spendbook: invalid book configuration")
 3	ErrEmptyOwner     = errors.New("spendbook: empty owner key")
 4	ErrEmptySpender   = errors.New("spendbook: empty spender key")
 5	ErrKeyTooLong     = errors.New("spendbook: key exceeds MaxKeyLen")
 6	ErrInvalidNow     = errors.New("spendbook: now must be non-negative")
 7	ErrInvalidTerms   = errors.New("spendbook: invalid terms")
 8	ErrInvalidAmount  = errors.New("spendbook: amount must be positive")
 9	ErrBookFull       = errors.New("spendbook: live allowance cap reached")
10	ErrNotFound       = errors.New("spendbook: no such live allowance")
11	ErrNotExpired     = errors.New("spendbook: allowance has not expired")
12	ErrNotOwner       = errors.New("spendbook: caller does not own this allowance")
13	ErrNotSpender     = errors.New("spendbook: caller is not this allowance's spender")
14	ErrExpired        = errors.New("spendbook: allowance has expired")
15	ErrNotStarted     = errors.New("spendbook: allowance has not started")
16	ErrPeriodExceeded = errors.New("spendbook: amount exceeds the window cap")
17	ErrTotalExceeded  = errors.New("spendbook: amount exceeds the lifetime cap")
18	ErrNoBoundary     = errors.New("spendbook: no reachable next window boundary")
19	ErrOverflow       = errors.New("spendbook: int64 overflow")
20	ErrIDExhausted    = errors.New("spendbook: identifier space exhausted")
21)
source

Errors returned by Book operations.

Functions 2

func MustNew

1func MustNew(maxLive int) *Book
source

MustNew is New but panics on error.

func New

1func New(maxLive int) (*Book, error)
source

New returns an empty Book. maxLive caps simultaneously live allowances and must be in [1, MaxLiveLimit].

Types 3

type Allowance

struct
 1type Allowance struct {
 2	ID        uint64
 3	Owner     string
 4	Spender   string
 5	Terms     Terms
 6	Anchor    int64 // window origin; also the earliest spendable instant
 7	CreatedAt int64
 8
 9	SpentTotal  int64 // over the allowance's whole life
10	SpentWindow int64 // within WindowIndex
11	WindowIndex int64
12
13	// Pending terms, applied at the next window boundary. Meaningful only
14	// when HasPending is true.
15	Pending    Terms
16	HasPending bool
17}
source

Allowance is one owner's grant to one spender. It is returned BY VALUE: holders cannot reach into a Book through it.

Methods on Allowance

func IsExpired

method on Allowance
1func (a Allowance) IsExpired(now int64) bool
source

IsExpired reports whether the allowance is past its expiry at now. An allowance with no expiry is never expired.

It resolves the expiry LIVE, through at(), and that is the whole difference between this package and v1 -- see "# Why v2 exists" in the package doc. Reading a.Terms.ExpiresAt directly answers from the last-written record, and after a boundary that record is stale in BOTH directions.

The now < Anchor guard is not a behaviour change and is not decoration. It preserves windowIndex's documented precondition: Remaining, Spend and Amend each reject a pre-anchor now before reaching here, but Reap does not, and IsExpired is EXPORTED, so a consumer may hand it any now at all. Without the guard such a now would reach a negative numerator in windowIndex, where Go's truncation-toward-zero folds two windows into one. false is also what v1 answered for every valid allowance there, so nothing moves: valid() requires ExpiresAt > Anchor, hence now < Anchor implies now < ExpiresAt.

func Remaining

method on Allowance
1func (a Allowance) Remaining(now int64) int64
source

Remaining reports how much the spender may draw right now: the lesser of what the current window leaves and what the lifetime cap leaves. It is 0 for an allowance that is expired, not yet started, or fully drawn.

It is a projection, not a reservation. Nothing is held, and the figure can change in the same block if someone else's call advances the clock past a boundary.

type Book

struct
1type Book struct {
2	maxLive int
3	nextID  uint64    // never decreases; IDs are never reused
4	live    *avl.Tree // padded id -> Allowance
5}
source

Book holds the live allowances of one consuming realm. The zero value is not usable; construct with New.

Methods on Book

func Amend

method on Book
1func (b *Book) Amend(id uint64, owner string, now int64, terms Terms) (bool, error)
source

Amend changes an allowance's terms. Only its owner may amend.

PeriodLength is immutable and must be passed unchanged. Terms that are weakly tighter in every dimension apply immediately; anything looser becomes pending and takes effect at the next window boundary. A lifetime allowance has no next boundary, so a loosening amendment on one is refused with ErrNoBoundary.

The returned bool reports whether the amendment applied immediately (true) or was queued (false). A caller that ignores it cannot tell whether the new ceiling is in force, which is exactly the thing worth knowing.

func Count

method on Book
1func (b *Book) Count() int
source

Count returns the number of live allowances.

func Get

method on Book
1func (b *Book) Get(id uint64) (Allowance, error)
source

Get returns a copy of a live allowance.

func Iterate

method on Book
1func (b *Book) Iterate(fn func(Allowance) bool)
source

Iterate calls fn for every live allowance in id order, stopping early if fn returns true. The allowance is passed by value, so fn cannot mutate the book through it.

func MaxLive

method on Book
1func (b *Book) MaxLive() int
source

MaxLive returns the Book's cap on simultaneously live allowances.

func Open

method on Book
1func (b *Book) Open(owner, spender string, now int64, terms Terms) (uint64, error)
source

Open records a new allowance and returns its id. now becomes the window anchor and the earliest instant the spender may draw.

An owner may open several allowances for the same spender; they are independent, and the ids are what tell them apart. spendbook does not merge or deduplicate them, because "one allowance per pair" is a policy some consuming realms want and others do not, and a primitive that decided it here could not be talked out of it.

func Reap

method on Book
1func (b *Book) Reap(id uint64, now int64) error
source

Reap removes an allowance that has expired at now, and may be called by anyone. It returns ErrNotFound for an unknown id and ErrNotExpired for one that is still alive.

Permissionless on purpose, and the reasoning is the whole justification: an expired allowance can never authorize another draw, so removing it takes nothing from the owner and grants nothing to the caller. Without it an expiry is a slow leak against maxLive -- Spend refuses the dead allowance forever while it holds a slot, so a Book shared between owners could fill with corpses and refuse new Opens, and the only remedy would be the owner of each corpse noticing and calling Revoke.

This relies on `now` exactly as much as Spend does. A realm that lets a caller choose `now` has already broken consumer contract item 1, and such a realm would let a caller reap a LIVE allowance by naming a future instant.

func Remaining

method on Book
1func (b *Book) Remaining(id uint64, now int64) int64
source

Remaining reports what the spender of id may draw at now, or 0 if there is no such live allowance. A caller that must distinguish "nothing left" from "no such allowance" should use Get.

func Revoke

method on Book
1func (b *Book) Revoke(id uint64, owner string) error
source

Revoke removes an allowance. Only its owner may revoke, and it takes effect immediately -- there is no window to wait for, because revocation is the owner's remedy and a delayed remedy is not one.

The id is not reused, so a revoked allowance cannot be resurrected: a later Spend on it returns ErrNotFound forever.

func Spend

method on Book
1func (b *Book) Spend(id uint64, spender string, now int64, amount int64) error
source

Spend consumes amount from allowance id on behalf of spender and returns nil if every bound permits it. The caller then performs its own effect.

On any error NOTHING is consumed. The write happens once, after the last check, so a refused Spend leaves the book exactly as it was.

type Terms

struct
1type Terms struct {
2	PerPeriod    int64
3	PeriodLength int64
4	Total        int64
5	ExpiresAt    int64
6}
source

Terms are the ceilings on an allowance. Zero has a distinct meaning in three of the four fields and it is never "zero allowed":

Example
1PerPeriod    > 0 required. The cap per window.
2PeriodLength 0 means ONE window that never rolls (lifetime).
3Total        0 means no lifetime cap.
4ExpiresAt    0 means no expiry.

Dependencies

Imports 3

/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook/v2

Imported by indexer

Source Files 2