spendbook
gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook on gno.land mainnetgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook on onyx-1 testnet
pure packageprimitiveaccess-controllive on mainnetlive on onyx-1
Let one account spend another's funds, up to a limit.
Gives a delegate a bounded, revocable allowance instead of handing over the whole balance.
In detail curated
gno.land mainnet
Pure allowance primitive: one party spends another party's funds up to a bounded amount per period, over many transactions. Owner opens/amends/revokes grants; spenders draw against per-period and total caps; amendments queue to the next period boundary. Deployed under SEC1 route (2): executable bytes byte-identical to the onyx-1 copy the budgets consumer exercised live; test file pruned from the payload by operator authorization (AL45). Carries two disclosed material YELLOWs, Y2 (under-authorizing) and Y3 (over-authorizing, bounded at one draw) -- see the deployment record; the fix is the separate application .../spendbook/v2.
onyx-1 testnet
Delegated spending allowances that survive across transactions. An allowance is a permission with a refilling ceiling, not a balance: PerPeriod per window, optional Total over its whole life, optional ExpiresAt, revocable by the owner at any instant. Windows are ANCHORED to the instant of Open and derived by integer division (index = (now - Anchor) / PeriodLength), not sliding, so a spender cannot reset a window by choosing when to call and the refill is a reset rather than a carry. Spend checks every bound and CONSUMES in the same call before returning, so the consuming realm performs its own effect and no closure, callback or capability ever crosses a realm boundary. Amendments are one-sided and that is the central safety property: tighter-or-equal terms apply immediately, anything looser is queued to the next window boundary and refused outright (ErrNoBoundary) when no reachable boundary exists -- a lifetime allowance, an arithmetically unrepresentable one, or an expiry at or before the next boundary. PeriodLength is immutable because changing it would move every future boundary. Spend and Remaining BOTH route through one at() function, so a read cannot disagree with the enforcer. Holds no coins, performs no effects, imports no chain APIs, owns no package-level state; the realm supplies `now`, which is the same caller-supplied-clock contract duebook uses.
One description per chain, because this application is deployed on more than one chain and each account was written for its own deployment. The programs differ only where the chain's namespace appears in the source — proved, not asserted, under Deployments below — so where the accounts differ they differ in emphasis and in which chain's history they describe, not in what the code does.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
on gno.land mainnet
import "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook"on onyx-1 testnet
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook"The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.
Called from real code
Not an example written for this page. These lines are quoted from budgets, a deployed package in this portfolio that imports this one as spendbook, out of the committed file whose sha256 gno.land mainnet attested. They compile because they are compiled.
book = spendbook.MustNew(MaxLiveGrants)Deployments
Deployed on 2 chains from the same source. The bytes are identical — one digest, dbb9ff98ada8f941…, on both — so the API below is the API on either. Everything that is not the bytes differs per chain and is stated per chain: the import path, the namespace, the transaction, the height, the cost, and which files the chain holds.
gno.land mainnet gnoland-1
chain-attested| Import path | gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook |
|---|---|
| Namespace | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Deployed at height | 1,236,132 |
| Deploy transaction | 387ec6eb7dfd11e6302448300a3f50ce6b485d7040a7ed7566624288b9b5a056 look it up on the RPC |
| Deployer | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Gas used | 34,492,538 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | gnomod.toml spendbook.gno |
| Deployed bytes | spendbook.gno — 25,023 bytes |
| sha256 | dbb9ff98ada8f9412e02ee8141e9c0970d17e1674fe17bc1ee6b7c96ecf88cb3 |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/budgets |
|---|
Do not take the hash above on trust. $download returns the bytes gnoland-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook$download&file=spendbook.gno' | shasum -a 256Expected: dbb9ff98ada8f9412e02ee8141e9c0970d17e1674fe17bc1ee6b7c96ecf88cb3 — 25,023 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 172,766 |
| Deploy transaction | fadcb6270dc4507f84e60134db84686d5b8fb8a2f58de963cc9ab80241788c68 look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 34,491,892 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | gnomod.toml spendbook.gno |
| Deployed bytes | spendbook.gno — 25,023 bytes |
| sha256 | dbb9ff98ada8f9412e02ee8141e9c0970d17e1674fe17bc1ee6b7c96ecf88cb3 |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/budgets, r/reimbursements |
|---|
Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook$download&file=spendbook.gno' | shasum -a 256Expected: dbb9ff98ada8f9412e02ee8141e9c0970d17e1674fe17bc1ee6b7c96ecf88cb3 — 25,023 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 2 exported functions, 3 types, 12 methods.
Read from vm/qdoc on gnoland-1. It describes the onyx-1 testnet deployment as well: the deployed bytes are identical on every chain here — one digest, dbb9ff98ada8f941… — and this site refuses to build a shared page for a package whose bytes differ across chains, so the same API is the API on each. The API is the only thing that carries across; every provenance fact above is per-chain.
This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.
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 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:
"Allowance survives across N txs but not N+1 txs." Use a session counter pattern in the granter realm; this package's Close is binary, 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":
if err := book.Spend(id, spenderKey, now, amount); err != nil { panic(err) // revoked, expired, over the window cap, over the total } // ... 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:
index(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)
- 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.
- 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.
- 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.
- 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.
Imports
errorsgno.land/p/nt/avl/v0strconv
Constants and variables
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 MaxKeyLen = 128
MaxLiveLimit is the largest maxLive a Book may be constructed with. It bounds the worst-case cost of Iterate, which scans the live set.
const MaxLiveLimit = 10000
Errors returned by Book operations.
var (
ErrInvalidConfig = errors.New("spendbook: invalid book configuration")
ErrEmptyOwner = errors.New("spendbook: empty owner key")
ErrEmptySpender = errors.New("spendbook: empty spender key")
ErrKeyTooLong = errors.New("spendbook: key exceeds MaxKeyLen")
ErrInvalidNow = errors.New("spendbook: now must be non-negative")
ErrInvalidTerms = errors.New("spendbook: invalid terms")
ErrInvalidAmount = errors.New("spendbook: amount must be positive")
ErrBookFull = errors.New("spendbook: live allowance cap reached")
ErrNotFound = errors.New("spendbook: no such live allowance")
ErrNotExpired = errors.New("spendbook: allowance has not expired")
ErrNotOwner = errors.New("spendbook: caller does not own this allowance")
ErrNotSpender = errors.New("spendbook: caller is not this allowance's spender")
ErrExpired = errors.New("spendbook: allowance has expired")
ErrNotStarted = errors.New("spendbook: allowance has not started")
ErrPeriodExceeded = errors.New("spendbook: amount exceeds the window cap")
ErrTotalExceeded = errors.New("spendbook: amount exceeds the lifetime cap")
ErrNoBoundary = errors.New("spendbook: no reachable next window boundary")
ErrOverflow = errors.New("spendbook: int64 overflow")
ErrIDExhausted = errors.New("spendbook: identifier space exhausted")
)
Types
type Allowance
type Allowance struct {
ID uint64
Owner string
Spender string
Terms Terms
Anchor int64 // window origin; also the earliest spendable instant
CreatedAt int64
SpentTotal int64 // over the allowance's whole life
SpentWindow int64 // within WindowIndex
WindowIndex int64
// Pending terms, applied at the next window boundary. Meaningful only
// when HasPending is true.
Pending Terms
HasPending bool
}
Allowance is one owner's grant to one spender. It is returned BY VALUE: holders cannot reach into a Book through it.
| Exported field | Type | Doc |
|---|---|---|
ID | uint64 | |
Owner | string | |
Spender | string | |
Terms | Terms | |
Anchor | int64 | window origin; also the earliest spendable instant |
CreatedAt | int64 | |
SpentTotal | int64 | over the allowance's whole life |
SpentWindow | int64 | within WindowIndex |
WindowIndex | int64 | |
Pending | Terms | Pending terms, applied at the next window boundary. Meaningful only when HasPending is true. |
HasPending | bool |
Allowance.IsExpired
func (a Allowance) IsExpired(now int64) bool
IsExpired reports whether the allowance is past its expiry at now. An allowance with no expiry is never expired.
Allowance.Remaining
func (a Allowance) Remaining(now int64) int64
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
type Book struct {
maxLive int
nextID uint64 // never decreases; IDs are never reused
live *avl.Tree // padded id -> Allowance
}
Book holds the live allowances of one consuming realm. The zero value is not usable; construct with New.
Book.Amend
func (b *Book) Amend(id uint64, owner string, now int64, terms Terms) (bool, error)
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.
Book.Count
func (b *Book) Count() int
Count returns the number of live allowances.
Book.Get
func (b *Book) Get(id uint64) (Allowance, error)
Get returns a copy of a live allowance.
Book.Iterate
func (b *Book) Iterate(fn func(Allowance) bool)
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.
Book.MaxLive
func (b *Book) MaxLive() int
MaxLive returns the Book's cap on simultaneously live allowances.
Book.Open
func (b *Book) Open(owner, spender string, now int64, terms Terms) (uint64, error)
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.
Book.Reap
func (b *Book) Reap(id uint64, now int64) error
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.
Book.Remaining
func (b *Book) Remaining(id uint64, now int64) int64
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.
Book.Revoke
func (b *Book) Revoke(id uint64, owner string) error
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.
Book.Spend
func (b *Book) Spend(id uint64, spender string, now int64, amount int64) error
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
type Terms struct {
PerPeriod int64
PeriodLength int64
Total int64
ExpiresAt int64
}
Terms are the ceilings on an allowance. Zero has a distinct meaning in three of the four fields and it is never "zero allowed":
PerPeriod > 0 required. The cap per window. PeriodLength 0 means ONE window that never rolls (lifetime). Total 0 means no lifetime cap. ExpiresAt 0 means no expiry.
Functions
MustNew
func MustNew(maxLive int) *Book
MustNew is New but panics on error.
New
func New(maxLive int) (*Book, error)
New returns an empty Book. maxLive caps simultaneously live allowances and must be in [1, MaxLiveLimit].
Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.
Dependencies
A first-party dependency is imported from the SAME chain's namespace, so each deployment above depends on that chain's copy — the paths differ even where the code does not.
chain-attested| Imports | errors, gno.land/p/nt/avl/v0, strconv |
|---|---|
| First-party dependencies | none |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curatedgno.land mainnet
- No transaction enters a /p/: nothing on gnoland-1 has yet executed these bytes inside a committing transaction. The onyx-1 proof (byte-identical source, budgets consumer, ten transactions) transfers at the strength of byte identity. The gnoland-1 budgets deploy, when selected and cleared, becomes this chain's commit-proof and must add 'r/budgets' to consumed_by here.
- Disclosed material YELLOWs, retained by operator decision 2026-10-31 (grounds_status in catalog/release_decisions.json): Y2, a queued expiry extension can be silently lost (owner-recoverable, Revoke + re-Open); Y3, an amendment raising PerPeriod while lowering ExpiresAt admits ONE draw at the raised cap past the amended expiry, self-correcting after the draw, with Reap refusing to clear the window (Y3-REAP). Probe: tools/probe_spendbook_expiry.sh, PINNED, 13 assertions. Fix: .../spendbook/v2, a separate registered application.
- The payload is testless by operator decision (SEC1 route 2, AL45): spendbook_test.gno is pruned from this tree. Coverage lives at onyx/p/spendbook/spendbook_test.gno and transfers by byte identity of the executable file; register gate ENG1 re-derives that identity per run.
onyx-1 testnet
- This package now HAS a live, exercised consumer: gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/budgets, deployed 2026-10-05 at height 186167, which drove ten real transactions through Open/Spend/Amend/Revoke against these deployed bytes. CORRECTED 2026-10-05 (second correction to this field): it opened 'NOTHING IS DEPLOYED THAT CONSUMES THIS PACKAGE. consumed_by is empty and that is the honest state', and went on to say that CROSS-TRANSACTION PERSISTENCE IS STILL UNDEMONSTRATED and still needs a consuming realm. Both were true when written and both are now FALSE, with transaction hashes behind the refutation: the queued loosening at height 186289 (tx 13b10861...) took effect at a window boundary and was honored by a draw 3,147 blocks later at height 189436 (tx f58bc910...), which no single transaction and no vm/qeval could have shown. The duebook precedent named in the superseded text is what happened here. The behavioural argument still rests mostly on the committed suite -- now 64 unit tests and 41 sabotage cases, up from the 61 and 40 recorded at deploy time, the three new tests and one new case being what exercising the package found -- but it is no longer the ONLY evidence. The earlier correction stands and is unaffected: vm/qeval does not dispatch through MsgCall and needs no consumer, 20 of 20 differential probes agree between the committed source and the deployed bytes (tools/probe_spendbook_live.py; onyx/p/spendbook/behavioral-probe-onyx1.json), six of them re-taken with chain_id_bound: true. See [an unpublished record], the 2026-10-05 Amendment and Group 9.
- THE EXPORTED Allowance FIELDS ARE THE LAST-WRITTEN RECORD, NOT THE LIVE VIEW, and the only live-resolved view reachable from outside this package is Remaining. Between a window boundary and the next write there is no write to refresh the record, so Terms, Pending, HasPending, WindowIndex and SpentWindow all describe a window the clock has already left. Found by its own first consumer: measured on chain, budgets' grant #1 held a stored cap of 200,000 while the enforcer correctly honored a draw of 800,000 in the same window -- a factor of four, in the dangerous direction, since the owner is shown LESS authority than the delegate holds. THIS IS NOT AN ENFORCEMENT BUG: Spend and Remaining both route through the one at() function and were correct in all ten live transactions. It is the SHAPE of the API, the hazard is bounded (any write heals the record), and the remedy a future version wants is an exported live view such as Allowance.At(now). Three regression tests now pin the observed behaviour. Only HALF of it is sabotageable and the harness says which: 'Remaining resolves live' is one expression and has a case, while 'the fields are stale' follows from Get taking no `now` at all, so there is no term to invert and a case for it could never fail. Consumers must derive the window live, as budgets does.
- The two largest safety properties are CONSUMER OBLIGATIONS the package cannot enforce, and both are stated in-source as items 1 and 2 of the consumer contract. (1) spendbook cannot verify that `now` came from a real clock. A realm that lets a caller choose `now` has handed the spender the power to roll the window at will, which defeats every cap here -- and it would also let a caller Reap a LIVE allowance by naming a future instant. (2) spendbook compares the spender 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 a caller-supplied argument.
- maxLive is a single GLOBAL figure, not a per-owner quota, so any owner permitted to Open can exhaust a shared Book and lock out every other owner. Left unfixed on purpose -- the remedy (per-owner cap? whitelist? first-come?) is a policy a primitive cannot pick -- but it means 'bounded storage' must not be read as 'safe to share between mutually untrusting owners'. Named in-source under State growth.
- An EXPIRED allowance is not self-removing: Spend refuses it forever while it still holds a maxLive slot. Reap closes that leak and is permissionless (an expired allowance can authorize nothing, so removing it takes nothing from anyone), but it still has to be CALLED by somebody, and nothing in this package calls it.
- simulate=true on onyx-1 does NOT type-check (the chain's inert code-submission policy), so the dry run before this broadcast was a gas/fee/authorization probe only. The correctness substitute is `gno lint .` against the onyx release, which was rc=0 on exactly these bytes.
Source
| Source file (gnoland-1) | mainnet/p/spendbook/spendbook.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
|---|---|
| Matches the deployed bytes (gnoland-1) | yes — byte-identical |
| Source file (onyx-1) | onyx/p/spendbook/spendbook.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
| Matches the deployed bytes (onyx-1) | yes — byte-identical |