← Back to Realms

Realm

gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets

Overview

Realm Path
gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets
Exported Functions
32
State Entries
26
Source Files
3
Total Package Entries
79

Exported Functions

32 exported functions

State

26 state entries

Source Code

FILES
budgets.gno
go
1// Realm budgets is a delegated-budget treasury in GNOT: an owner funds a
2// pool, grants named delegates a standing, rate-limited, revocable
3// authority to spend from it, and each delegate then draws within that
4// authority with no further approval from anyone.
5//
6// WHY THIS EXISTS (see DISCOVERY.md): every disbursement path on either
7// live chain is one of three shapes — a fresh group decision per spend
8// (this portfolio's treasury_board, gno.land's r/gov/dao/treasury, the
9// mainnet bubbletreasury), a fixed schedule (vesting), or a pull against
10// an already-settled entitlement (fee_split, revshare, crowdfund,
11// subscriptions). None of them lets one party spend another's money at
12// its own discretion within a bound. That is the gap, and
13// bubbletreasury — a real mainnet treasury that hand-rolled a sliding
14// spend window inside the realm, with every spend still a token-holder
15// proposal — is the evidence someone wanted it.
16//
17// It is also, deliberately, the FIRST CONSUMER of spendbook on any
18// chain. A primitive with no consumer has two properties unproven no
19// matter how many unit tests pass: that its API is usable from a realm,
20// and that its state survives across transactions. Only a realm can
21// answer either, and this one is the answer.
22//
23// COMPOSITION: spending authority is delegated entirely to spendbook
24// (anchored windows, lifetime caps, expiry, one-sided amendments,
25// revocation), coin movement to coinio, per-delegate credit and the fee
26// pot to feeledger, ordered storage to p/nt/avl/v0, and free-text render
27// output to p/nt/markdown/sanitize/v0. This realm owns only the pool
28// accounting, the grant records, and the solvency rule.
29//
30// # What a budget is, and what it is not
31//
32// A budget is AUTHORITY, not a reservation. Three grants of 100 against
33// a pool of 100 are all three valid, and the first delegate to draw gets
34// the money. This is not a defect to be fixed by reserving funds: a
35// reservation would make an owner unable to grant a standing allowance
36// without pre-committing the capital, which is the whole convenience
37// being bought. What the realm guarantees instead is that it never
38// PROMISES what it does not HOLD:
39//
40//	Draw refuses unless amount <= the owner's own unspent pool.
41//
42// So a delegate is told "no" at draw time rather than credited with
43// something unclaimable. The pool is the solvency bound and it is
44// per-owner, which is strictly stronger than a realm-wide check: one
45// owner's grants can never reach another owner's funds.
46//
47// # Credit, then pull
48//
49// Draw moves nothing. It consumes the allowance, debits the owner's
50// pool, and credits the delegate in the ledger; the delegate then signs
51// a separate Claim to receive the coins. This is not ritual. If Draw
52// paid out directly, a failing payout inside the same transaction would
53// leave a consumed allowance with no money delivered, or force the
54// allowance consumption to happen after an effect — and spendbook's own
55// consumer contract item 3 says state first, effect second, for exactly
56// that reason. Splitting it also means a delegate can draw repeatedly
57// and claim once, which is cheaper than claiming per draw.
58//
59// # LIFECYCLE
60//
61//	Fund (owner, +send)       : adds real coins to the caller's own pool.
62//	                            Anyone may fund; funding makes you an
63//	                            owner. Minimum MinFunding.
64//	WithdrawPool (owner)      : pulls unspent pool back out, any amount,
65//	                            any time. See "the owner can always
66//	                            leave" below.
67//	WithdrawPoolAll (owner)   : the whole pool.
68//	Grant (owner)             : opens one spendbook allowance for one
69//	                            delegate and returns the grant id. The
70//	                            draw fee is snapshotted here.
71//	Amend (owner)             : changes the grant's terms. Tighter-or-
72//	                            equal applies now; looser waits for the
73//	                            next window boundary. PeriodLength is
74//	                            NOT a parameter — spendbook makes it
75//	                            immutable and this realm makes that
76//	                            structural.
77//	Revoke (owner)            : immediate, and the id is never reused.
78//	Draw (DELEGATE)           : consumes authority, debits the pool,
79//	                            credits the delegate net of the fee.
80//	Claim (DELEGATE)          : pays the delegate's whole credit out.
81//	Reap (ANYONE)             : removes an EXPIRED grant, freeing its
82//	                            slot. Permissionless because an expired
83//	                            allowance can authorize nothing, so
84//	                            removing it takes nothing from anyone.
85//	WithdrawFees (ANYONE)     : pays the fee pot to the compile-time
86//	                            FeeCollector. Fixed destination, so
87//	                            there is nothing to steer.
88//	SweepSurplus (ANYONE)     : out-of-band coins to the FeeCollector,
89//	                            never touching tracked liabilities.
90//
91// # The owner can always leave, and that is the design
92//
93// WithdrawPool has no lock, no notice period, and no check against
94// outstanding grants. An owner can empty the pool under a live grant and
95// the delegate's next Draw will fail on solvency. This is stated plainly
96// rather than buried: the money is the owner's, Revoke is the clean way
97// to end a relationship, and a realm that could trap an owner's capital
98// behind a grant they regret would be a worse realm. A delegate who
99// needs a guarantee of funds does not want a budget — they want escrow,
100// which is a different artifact.
101//
102// What the owner CANNOT do is undo work already authorized: credit
103// already drawn into the ledger is the delegate's, and Revoke does not
104// claw it back. Revoking a grant ends future authority only.
105//
106// # THE FEE, precisely
107//
108// feeBps is snapshotted into the grant at Grant time from the
109// compile-time DrawFeeBps, so a delegate's terms are fixed when the
110// grant is created and visible in GrantInfo from that moment. The fee is
111// charged ONCE per draw, on the gross amount, with feeledger's floor
112// rounding (rounding favors the delegate). The owner's pool is debited
113// the GROSS amount and the delegate is credited the net — the fee is
114// paid by the owner out of the budget, not taken out of the delegate's
115// authority, so a grant of PerPeriod means PerPeriod of the owner's
116// money leaves the pool per window, which is the figure an owner
117// actually cares about bounding. The ledger is constructed with the
118// compile-time MaxFeeBps cap, so a fee above the cap is refused by the
119// primitive, not by a check in this file. There is no fee on funding, on
120// withdrawing an unspent pool, or on claiming.
121//
122// # THERE IS NO ADMIN
123//
124// The fee rate, the fee collector, every bound and every cap are
125// compile-time constants. No address can change terms, pause the realm,
126// touch a pool it did not fund, or move a grant it did not create. The
127// deployer has no privilege of any kind after deployment.
128//
129// # MONETARY INVARIANT (conservation)
130//
131// Let H be the ugnot held at this realm's address, P the sum of all
132// owner pools (tracked O(1) as totalPooled), L the ledger's liabilities
133// (unclaimed delegate credit plus the fee pot), and S >= 0 the
134// out-of-band surplus:
135//
136//	H == P + L + S
137//
138// Every transition moves value between exactly two terms inside one
139// transaction. Fund raises H and P together (coinio.Receive is the
140// receipt-guaranteed shape). WithdrawPool debits P before the identical
141// amount leaves H. Draw moves gross out of P and into L, split into the
142// delegate's credit and the fee — H does not move at all, which is why
143// Draw has no payout to fail. Claim and WithdrawFees debit L before
144// their payout. Any panic aborts the whole transaction; this realm never
145// issues or removes coins.
146//
147// # ONLY GNOT IS ACCEPTED
148//
149// Fund rejects any envelope that is not exactly one positive ugnot coin
150// (coinio.Receive). Every other entrypoint rejects attached coins
151// outright rather than converting them into sweepable surplus.
152//
153// # AUTHORIZATION
154//
155// Every identity is derived from the crossing entrypoint's
156// cur.Previous().Address(). No function takes a caller identity as a
157// parameter, so funding another owner's pool, drawing another delegate's
158// budget, claiming another delegate's credit, or amending a grant you do
159// not own is impossible by construction. This is spendbook consumer
160// contract item 2, closed structurally.
161//
162// The clock is runtime.ChainHeight(), never an argument. That is
163// spendbook consumer contract item 1: a realm that let a caller choose
164// `now` would have handed the delegate the power to roll the window at
165// will, defeating every cap in the primitive.
166//
167// # STORAGE
168//
169// A pool entry is paid for by the owner's own funding transaction; a
170// grant record and its allowance by the owner's Grant. Revoke and Reap
171// both remove the grant record and the allowance in the same call, and
172// the chain refunds the freed storage deposit to the transaction that
173// frees it — which is the standing incentive to call Reap on someone
174// else's expired grant.
175package budgets
176
177import (
178	"chain"
179	"chain/runtime"
180	"chain/runtime/unsafe"
181	"strconv"
182	"strings"
183
184	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
185	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"
186	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook"
187	"gno.land/p/nt/avl/v0"
188	"gno.land/p/nt/markdown/sanitize/v0"
189)
190
191// RealmPath is this realm's own path, used to build Render links.
192const RealmPath = "/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets"
193
194// Denom is the only asset this realm holds.
195const Denom = "ugnot"
196
197// MaxFeeBps is the hard ceiling on any draw fee this realm could ever
198// charge, enforced by the feeledger the realm is constructed with — a
199// feeBps above it is refused by the primitive's own validation, not by a
200// check in this file. 500 bps = 5%.
201const MaxFeeBps = int64(500)
202
203// DrawFeeBps is the fee actually snapshotted into every grant: 100 bps =
204// 1%, charged once per draw on the gross amount, floor-rounded in the
205// delegate's favor. The most conservative live fee in this portfolio
206// (fee_split, crowdfund) is also 1%.
207const DrawFeeBps = int64(100)
208
209// FeeCollector receives withdrawn fees and swept surplus. Compile-time
210// constant: there is no setter and no transfer path. It is the deploying
211// namespace's address — stated plainly: the operator of this realm.
212const FeeCollector = address("g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39")
213
214// MinFunding keeps dust pools out: 1 GNOT per Fund call.
215const MinFunding = int64(1_000_000)
216
217// MinPerPeriod is the smallest per-window cap a grant may carry. It
218// exists so a grant cannot be created that no draw could ever satisfy,
219// and it is the ONLY lower bound in the draw path — there is
220// deliberately no minimum Draw amount, because a minimum draw above a
221// grant's PerPeriod would make the grant silently undrawable.
222const MinPerPeriod = int64(1_000)
223
224// MinPeriodBlocks is the shortest rolling window a grant may use. A
225// window of a handful of blocks is a rate, not a budget, and it makes
226// PerPeriod meaningless as a bound on worst-case loss. A periodLength of
227// 0 is still accepted and means ONE LIFETIME WINDOW that never refills
228// (spendbook's own semantics).
229const MinPeriodBlocks = int64(100)
230
231// MaxLiveGrants caps simultaneously live grants, via spendbook's own
232// construction bound. Grant is permissionless to anyone with a funded
233// pool, so the cap is exhaustible in principle, and that AVAILABILITY
234// BOUND is documented rather than hidden.
235//
236// It is documented here in its CORRECTED form. An earlier version of
237// this comment claimed the mitigation was "MaxGrantsPerOwner plus the
238// cost of becoming an owner at all (MinFunding per pool), and Reap is
239// permissionless so expired grants cannot accumulate". An adversarial
240// audit (finding Y1, DISCOVERY.md § 9) showed BOTH HALVES were false. A
241// wrong mitigation is worse than an acknowledged bound, because a reader
242// sizes the risk from it and stops looking:
243//
244//   - MinFunding is NOT a capital bound. The funded-pool requirement is
245//     checked at Grant time only, and WithdrawPool has no lock, so one
246//     address funds a pool, opens MaxGrantsPerOwner grants, withdraws
247//     the pool in full, and the grants survive holding their slots.
248//     TestOwnerCanAlwaysLeave pins exactly that sequence, as intended
249//     behavior — the attack is built out of a documented feature.
250//   - Reap is inert against it. Reap requires spendbook's IsExpired,
251//     which is never true for expiresAt == 0, so an attacker omits the
252//     expiry and no third party can remove the grant.
253//
254// What the cost ACTUALLY is: gas for one Fund, MaxGrantsPerOwner Grants
255// and one WithdrawPoolAll per address — about MaxLiveGrants*17/16
256// transactions to fill the cap — plus the storage deposit on every grant
257// record, which the chain holds for as long as the grant does and
258// refunds only when it is removed. That is real and unrecoverable while
259// the grief lasts. It is not the locked pool capital the old comment
260// promised, and it is priced in gas and storage rather than in capital.
261//
262// No capital bound was added, because the obvious one does not work.
263// Requiring an owner with live grants to retain MinFunding is defeated
264// for the price of the draw fee: nothing forbids granting to YOURSELF,
265// so the attacker draws its own pool to zero through its own grant and
266// Claims the credit back, recovering all but DrawFeeBps of it while the
267// retention rule still reads as satisfied. Closing THAT would mean
268// bounding Draw below MinFunding — the realm refusing to let a delegate
269// spend the budget, which is the feature. The remaining alternative, a
270// non-refundable per-grant fee, taxes every honest owner to price a
271// grief, and at a fee high enough to deter one it is a tenth of
272// MinFunding per grant.
273//
274// The blast radius is narrow and that is the reason this is disclosed
275// rather than redesigned: a full book blocks NEW grants only. Every
276// existing grant, pool, draw, credit and claim keeps working, no value
277// is at risk, and any owner can free their own slots with Revoke.
278//
279// The figure was 1024 until that audit, and 1024 was conservative rather
280// than measured: nothing in this realm iterates the book (Render is
281// bounded by MaxRenderRows, every write path is a single avl lookup) and
282// a grant record's storage is paid for by whoever creates it.
283// Quadrupling it quadruples the cost of filling the cap and costs an
284// honest owner nothing. spendbook's own MaxLiveLimit is 10000.
285const MaxLiveGrants = 4096
286
287// MaxGrantsPerOwner bounds how much of the global cap one owner can
288// occupy. spendbook's own package documentation names this as the gap a
289// consuming realm must close — "maxLive is a single global figure, so
290// any owner permitted to Open can exhaust it and lock out the rest …
291// the consuming realm decides who may Open, and a Book shared across
292// mutually untrusting owners needs its own per-owner cap on top of this
293// one." This is that cap. (permission_registry finding R1 is the same
294// lesson from this portfolio's own history.)
295const MaxGrantsPerOwner = int64(16)
296
297// MaxLabelLen bounds the grant's free-text label, which is sanitized
298// before reaching markdown.
299const MaxLabelLen = 64
300
301// MaxRenderRows bounds the grant list on the index page.
302const MaxRenderRows = 20
303
304type grant struct {
305	id        int64
306	owner     address
307	spender   address
308	allowID   uint64 // the spendbook allowance backing this grant
309	feeBps    int64  // snapshotted at Grant
310	label     string
311	createdAt int64
312
313	drawn int64 // lifetime gross drawn under this grant
314	draws int64 // number of successful draws
315}
316
317var (
318	self address
319
320	// grants holds one record per live grant. Revoke and Reap remove the
321	// record; ids are never reused.
322	grants = avl.NewTree() // padID(id) -> *grant
323	nextID int64
324
325	// pools is each owner's unspent funded balance:
326	// address string -> int64 (> 0). This is the P term.
327	pools = avl.NewTree()
328
329	// openByOwner tracks each owner's live grant count:
330	// address string -> int64 (> 0).
331	openByOwner = avl.NewTree()
332
333	// totalPooled is the P term of H == P + L + S, maintained O(1).
334	totalPooled int64
335
336	// lifetime counters, rendered for operators.
337	lifetimeFunded   int64
338	lifetimeDrawn    int64 // gross, before the fee
339	lifetimeClaimed  int64 // net, paid to delegates
340	lifetimeReturned int64 // unspent pool withdrawn by owners
341
342	// ledger holds each delegate's unclaimed credit and the fee pot. Its
343	// Liabilities() is the L term and the reserve SweepSurplus protects.
344	ledger = feeledger.MustNew(MaxFeeBps)
345
346	// book holds one live allowance per live grant. Every bound on
347	// authority — windows, caps, expiry, amendment direction — is the
348	// primitive's, not this file's.
349	book = spendbook.MustNew(MaxLiveGrants)
350)
351
352func init() {
353	self = unsafe.CurrentRealm().Address()
354}
355
356// rejectStraySend aborts when coins are attached to a non-payable call.
357// This realm holds funds, so a stray send would become sweepable
358// surplus, silently converting a user's coins into operator funds.
359// Aborting reverts the transfer instead. Guarded on IsUserCall, not
360// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope
361// before forwarding control; for a realm-routed call the envelope lands
362// at the intermediary, so there is nothing here to reject and the guard
363// deliberately fails open (crowdfund and treasury_board document the
364// same scope).
365func rejectStraySend(cur realm) {
366	if !cur.IsCurrent() {
367		// Every call site forwards a crossing entrypoint's own live cur,
368		// so this cannot fire today; it exists so a future refactor that
369		// hands this guard a stale realm value fails closed, not open
370		// (coinio names IsCurrent as the guard secondary realm
371		// parameters require, and this helper follows that discipline).
372		panic("budgets: realm capability is not current")
373	}
374	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
375		panic("this entrypoint does not accept coins")
376	}
377}
378
379// --- the pool ---
380
381// Fund adds the attached coins to the caller's own pool. Direct EOA
382// calls with -send only (the receipt-guaranteed shape); exactly one
383// positive ugnot coin, at least MinFunding. Anyone may fund; funding is
384// what makes an address an owner.
385func Fund(cur realm) int64 {
386	owner, amount := coinio.Receive(0, cur, Denom)
387	if amount < MinFunding {
388		panic("fund at least " + itoa(MinFunding) + Denom)
389	}
390	key := owner.String()
391
392	newPool, ok := checkedAdd(poolOf(key), amount)
393	if !ok {
394		panic("pool would overflow")
395	}
396	newTotal, ok := checkedAdd(totalPooled, amount)
397	if !ok {
398		panic("total pooled would overflow")
399	}
400	newLifetime, ok := checkedAdd(lifetimeFunded, amount)
401	if !ok {
402		panic("lifetime funded would overflow")
403	}
404
405	setPool(key, newPool)
406	totalPooled = newTotal
407	lifetimeFunded = newLifetime
408
409	chain.Emit("Funded", "owner", key, "amount", itoa(amount),
410		"pool", itoa(newPool))
411	return newPool
412}
413
414// WithdrawPool returns part of the caller's unspent pool and reports the
415// pool REMAINING afterwards. There is no lock and no check against
416// outstanding grants: the money is the owner's, and a delegate's
417// authority is not a claim on it (see the package doc, "the owner can
418// always leave"). Revoke is the clean way to end a grant.
419func WithdrawPool(cur realm, amount int64) int64 {
420	rejectStraySend(cur)
421	owner := cur.Previous().Address()
422	if amount <= 0 {
423		panic("amount must be positive")
424	}
425	return payOutPool(cur, owner, amount)
426}
427
428// WithdrawPoolAll returns the caller's entire unspent pool and reports the
429// amount WITHDRAWN.
430//
431// The return values of the two withdraw entrypoints are deliberately
432// asymmetric, which is worth naming rather than letting a caller discover:
433// each returns the figure its caller does not already have. WithdrawPool
434// was told the amount and wants to know what is left; WithdrawPoolAll
435// knows what is left (nothing) and wants to know what it got. Returning
436// the remainder here would hand every caller a constant 0.
437func WithdrawPoolAll(cur realm) int64 {
438	rejectStraySend(cur)
439	owner := cur.Previous().Address()
440	amount := poolOf(owner.String())
441	if amount == 0 {
442		panic("no pool to withdraw")
443	}
444	payOutPool(cur, owner, amount)
445	return amount
446}
447
448// payOutPool is the shared debit-then-send path: the pool and the P term
449// are reduced before the identical amount leaves the realm
450// (checks-effects-interactions).
451func payOutPool(cur realm, owner address, amount int64) int64 {
452	key := owner.String()
453	pool := poolOf(key)
454	if amount > pool {
455		panic("amount exceeds pool")
456	}
457	newReturned, ok := checkedAdd(lifetimeReturned, amount)
458	if !ok {
459		panic("lifetime returned would overflow")
460	}
461
462	setPool(key, pool-amount)
463	totalPooled -= amount
464	lifetimeReturned = newReturned
465
466	coinio.Payout(0, cur, owner, Denom, amount)
467
468	chain.Emit("PoolWithdrawn", "owner", key, "amount", itoa(amount),
469		"pool", itoa(pool-amount))
470	return pool - amount
471}
472
473// --- grants ---
474
475// Grant opens a standing spending authority for one delegate and returns
476// the grant id. The caller becomes the grant's owner and must already
477// have a funded pool — not because the grant reserves anything (it does
478// not), but because a grant from an address that has never funded
479// anything is a promise with nothing behind it, and this realm declines
480// to record one.
481//
482// Terms, all in the chain's own units:
483//
484//	perPeriod    the cap per window, at least MinPerPeriod. Required.
485//	periodLength blocks per window. 0 means ONE LIFETIME WINDOW that
486//	             never refills; otherwise at least MinPeriodBlocks.
487//	total        lifetime cap across every window. 0 means unlimited.
488//	expiresAt    absolute block height at which authority ends. 0 means
489//	             no expiry.
490//
491// Every bound above except the two realm minimums is validated and
492// enforced by spendbook, and an invalid combination surfaces as that
493// package's own sentinel error.
494func Grant(cur realm, spender address, label string,
495	perPeriod, periodLength, total, expiresAt int64) int64 {
496	rejectStraySend(cur)
497	owner := cur.Previous().Address()
498
499	// The lowercase clause is load-bearing, not decoration, and the reason
500	// was measured on this toolchain rather than argued: address.IsValid()
501	// defers to crypto.AddressFromBech32, whose decoder lowercases the
502	// input BEFORE verifying the checksum, so it returns true for the
503	// all-uppercase spelling of a valid address. Here the hazard is not a
504	// double count (as in fee_split/v2 and treasury_board, where the
505	// address was a vote-bearing key) but a BRICKED GRANT: Draw
506	// authenticates against cur.Previous().Address(), which is always
507	// canonical, so an uppercase-spelled delegate could never draw, while
508	// the grant still occupied a MaxGrantsPerOwner slot and a storage
509	// deposit. The check is complete as well as necessary — that decoder
510	// rejects mixed case outright, so a valid address has exactly two
511	// spellings and rejecting every non-lower one leaves precisely the
512	// canonical one.
513	if !spender.IsValid() || string(spender) != strings.ToLower(string(spender)) {
514		panic("spender is not a valid address in canonical (lowercase) form")
515	}
516	if len(label) > MaxLabelLen {
517		panic("label exceeds " + strconv.Itoa(MaxLabelLen) + " bytes")
518	}
519	if perPeriod < MinPerPeriod {
520		panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
521	}
522	if periodLength != 0 && periodLength < MinPeriodBlocks {
523		panic("periodLength must be 0 (lifetime) or at least " +
524			itoa(MinPeriodBlocks) + " blocks")
525	}
526
527	key := owner.String()
528	if poolOf(key) == 0 {
529		panic("fund a pool before granting")
530	}
531	if ownerOpen(key) >= MaxGrantsPerOwner {
532		panic("owner already has " + itoa(MaxGrantsPerOwner) + " live grants")
533	}
534
535	now := runtime.ChainHeight()
536	allowID, err := book.Open(key, spender.String(), now, spendbook.Terms{
537		PerPeriod:    perPeriod,
538		PeriodLength: periodLength,
539		Total:        total,
540		ExpiresAt:    expiresAt,
541	})
542	if err != nil {
543		panic(err)
544	}
545
546	id := nextID + 1
547	nextID = id
548	grants.Set(padID(id), &grant{
549		id:        id,
550		owner:     owner,
551		spender:   spender,
552		allowID:   allowID,
553		feeBps:    DrawFeeBps,
554		label:     label,
555		createdAt: now,
556	})
557	setOwnerOpen(key, ownerOpen(key)+1)
558
559	chain.Emit("Granted", "id", itoa(id), "owner", key,
560		"spender", spender.String(), "perPeriod", itoa(perPeriod),
561		"periodLength", itoa(periodLength), "total", itoa(total),
562		"expiresAt", itoa(expiresAt), "feeBps", itoa(DrawFeeBps))
563	return id
564}
565
566// Amend changes a grant's terms. Owner only. The returned bool reports
567// whether the new terms are in force NOW (a tighter-or-equal amendment)
568// or queued for the next window boundary (a looser one) — spendbook's
569// one-sided rule, and the distinction worth knowing.
570//
571// periodLength is NOT a parameter. spendbook makes it immutable because
572// moving it would move every future boundary, which is an enlargement
573// vector wearing a different hat; this realm reads the grant's own
574// periodLength and passes it unchanged, so changing it is not merely
575// refused, it is unexpressible.
576func Amend(cur realm, id int64, perPeriod, total, expiresAt int64) bool {
577	rejectStraySend(cur)
578	owner := cur.Previous().Address()
579	g := mustGet(id)
580	if owner != g.owner {
581		panic("owner only")
582	}
583	if perPeriod < MinPerPeriod {
584		panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
585	}
586
587	a, err := book.Get(g.allowID)
588	if err != nil {
589		panic(err)
590	}
591	applied, err := book.Amend(g.allowID, owner.String(), runtime.ChainHeight(),
592		spendbook.Terms{
593			PerPeriod:    perPeriod,
594			PeriodLength: a.Terms.PeriodLength, // immutable
595			Total:        total,
596			ExpiresAt:    expiresAt,
597		})
598	if err != nil {
599		panic(err)
600	}
601
602	chain.Emit("Amended", "id", itoa(g.id), "owner", owner.String(),
603		"perPeriod", itoa(perPeriod), "total", itoa(total),
604		"expiresAt", itoa(expiresAt), "applied", boolstr(applied))
605	return applied
606}
607
608// Revoke ends a grant immediately. Owner only. The id is never reused,
609// so a revoked grant cannot be resurrected and a later Draw against it
610// fails as unknown.
611//
612// Revoking does NOT claw back credit the delegate has already drawn —
613// that is theirs and stays claimable. Revoke ends future authority.
614func Revoke(cur realm, id int64) {
615	rejectStraySend(cur)
616	owner := cur.Previous().Address()
617	g := mustGet(id)
618	if owner != g.owner {
619		panic("owner only")
620	}
621	// Owner-gated twice on purpose: the check above is for the error
622	// message, the primitive's is the authoritative one.
623	if err := book.Revoke(g.allowID, owner.String()); err != nil {
624		panic(err)
625	}
626	drop(g)
627
628	chain.Emit("Revoked", "id", itoa(g.id), "owner", owner.String(),
629		"spender", g.spender.String(), "drawn", itoa(g.drawn))
630}
631
632// Reap removes a grant whose authority has EXPIRED, freeing its slot and
633// its storage. Anyone may call it, and the reasoning is spendbook's own:
634// an expired allowance can never authorize another draw, so removing it
635// takes nothing from the owner and grants nothing to the caller. The
636// chain refunds the freed storage deposit to the reaping transaction,
637// which is the standing incentive.
638//
639// A grant with no expiry can never be reaped; only its owner can end it.
640func Reap(cur realm, id int64) {
641	rejectStraySend(cur)
642	g := mustGet(id)
643	if err := book.Reap(g.allowID, runtime.ChainHeight()); err != nil {
644		panic(err)
645	}
646	drop(g)
647
648	chain.Emit("Reaped", "id", itoa(g.id), "owner", g.owner.String(),
649		"by", cur.Previous().Address().String())
650}
651
652// --- drawing ---
653
654// Draw spends amount from the grant's budget. DELEGATE ONLY: the spender
655// is the calling address, never an argument. Nothing is paid out here —
656// the delegate is credited net of the fee and claims separately.
657//
658// Three things must all hold and they fail in this order, which matters:
659// the caller must be the grant's delegate and the amount must be within
660// every spendbook bound (window cap, lifetime cap, expiry, start); and
661// the owner's pool must cover the GROSS amount. The solvency check runs
662// BEFORE the allowance is consumed, so a draw refused for insolvency
663// leaves the budget untouched rather than burning window capacity on a
664// draw that did not happen.
665func Draw(cur realm, id int64, amount int64) int64 {
666	rejectStraySend(cur)
667	spender := cur.Previous().Address()
668	g := mustGet(id)
669	if spender != g.spender {
670		panic("delegate only")
671	}
672	if amount <= 0 {
673		panic("amount must be positive")
674	}
675
676	ownerKey := g.owner.String()
677	pool := poolOf(ownerKey)
678	if amount > pool {
679		// A budget is authority, not a reservation (package doc). The
680		// honest failure is here, at draw time, rather than a credit the
681		// delegate could not claim.
682		panic("owner's pool cannot cover this draw")
683	}
684
685	// Authority. spendbook checks every bound and CONSUMES the allowance
686	// in this one call, before returning; on any error nothing is
687	// consumed. Its sentinel errors name the reason.
688	if err := book.Spend(g.allowID, spender.String(),
689		runtime.ChainHeight(), amount); err != nil {
690		panic(err)
691	}
692
693	newDrawn, ok := checkedAdd(g.drawn, amount)
694	if !ok {
695		panic("grant drawn total would overflow")
696	}
697	newLifetime, ok := checkedAdd(lifetimeDrawn, amount)
698	if !ok {
699		panic("lifetime drawn would overflow")
700	}
701
702	// Move the gross out of P and into L. H does not move, which is why
703	// this transition has no payout to fail.
704	setPool(ownerKey, pool-amount)
705	totalPooled -= amount
706	credited, fee := ledger.MustDeposit(spender.String(), amount, g.feeBps)
707	g.drawn = newDrawn
708	g.draws++
709	lifetimeDrawn = newLifetime
710
711	chain.Emit("Drawn", "id", itoa(g.id), "spender", spender.String(),
712		"gross", itoa(amount), "credited", itoa(credited), "fee", itoa(fee),
713		"pool", itoa(pool-amount))
714	return credited
715}
716
717// Claim pays the caller's entire accrued credit out. DELEGATE ONLY: the
718// account is the calling address. Credit from a grant that has since
719// been revoked, expired or reaped is still claimable — it was earned
720// under a live authority and this realm does not revisit that.
721func Claim(cur realm) int64 {
722	rejectStraySend(cur)
723	account := cur.Previous().Address()
724	key := account.String()
725
726	amount, err := ledger.WithdrawAll(key)
727	if err != nil {
728		panic(err)
729	}
730	if amount == 0 {
731		panic("no credit to claim")
732	}
733	newClaimed, ok := checkedAdd(lifetimeClaimed, amount)
734	if !ok {
735		panic("lifetime claimed would overflow")
736	}
737	lifetimeClaimed = newClaimed
738
739	coinio.Payout(0, cur, account, Denom, amount)
740
741	chain.Emit("Claimed", "account", key, "amount", itoa(amount))
742	return amount
743}
744
745// --- fees and surplus ---
746
747// WithdrawFees pays the entire accrued fee pot to the compile-time
748// FeeCollector. Anyone may call it: the destination is fixed, so there
749// is nothing for a caller to steer.
750func WithdrawFees(cur realm) int64 {
751	rejectStraySend(cur)
752	fees := ledger.WithdrawFees()
753	if fees == 0 {
754		panic("no fees accrued")
755	}
756	coinio.Payout(0, cur, FeeCollector, Denom, fees)
757	chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
758	return fees
759}
760
761// SweepSurplus sends out-of-band coins to the FeeCollector: for the pool
762// denom, everything above tracked liabilities; for any foreign denom,
763// the full balance. Owner pools, delegate credit and the fee pot are
764// untouchable by construction (coinio.Sweep refuses to dip below the
765// reserve). Anyone may call it.
766//
767// With nothing out of band this ABORTS ("coinio: no surplus to sweep for
768// <denom>") rather than returning 0. That is the primitive's choice and
769// the right one: a sweep is a transfer, and a transfer of nothing that
770// reports success is how a caller comes to believe a balance was moved.
771func SweepSurplus(cur realm, denom string) int64 {
772	rejectStraySend(cur)
773	reserve := int64(0)
774	if denom == Denom {
775		reserve = Liabilities()
776	}
777	swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve)
778	chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(),
779		"amount", itoa(swept))
780	return swept
781}
782
783// --- views ---
784
785// GrantInfo returns a grant's parties and the terms currently RECORDED
786// for it. perPeriod reflects a tightening amendment immediately; a
787// queued loosening shows in PendingTerms until its boundary arrives.
788//
789// CAVEAT (audit 2026-11-04, N1): past the first window boundary after a
790// loosening was queued, the PENDING terms already govern enforcement --
791// spendbook's at() applies them whether or not any write observed the
792// roll -- while this function still returns the stored record until the
793// next draw or amendment. For the governing figures, read GrantUsage /
794// Drawable (always boundary-correct) or the grant page, which derives
795// its terms exactly as the gate does.
796func GrantInfo(id int64) (owner, spender address, perPeriod, periodLength,
797	total, expiresAt, anchor, createdAt, feeBps int64) {
798	g := mustGet(id)
799	a := mustAllowance(g)
800	return g.owner, g.spender, a.Terms.PerPeriod, a.Terms.PeriodLength,
801		a.Terms.Total, a.Terms.ExpiresAt, a.Anchor, g.createdAt, g.feeBps
802}
803
804// GrantUsage returns a grant's consumption AT THE CURRENT HEIGHT: what the
805// window the chain is in right now has spent, the lifetime gross, that
806// window's index, and how many draws there have been.
807//
808// The two window figures are computed rather than read straight off the
809// record, and that is the whole point of windowAt below: a window that has
810// rolled with no draw since leaves Allowance.WindowIndex naming a window
811// the chain has already left, and Allowance.SpentWindow holding the
812// previous window's spend. spentTotal and draws are cumulative and never
813// go stale.
814func GrantUsage(id int64) (spentWindow, spentTotal, windowIndex, draws int64) {
815	g := mustGet(id)
816	a := mustAllowance(g)
817	now := runtime.ChainHeight()
818	return spentInWindow(a, now), a.SpentTotal, windowAt(a, now), g.draws
819}
820
821// GrantLabel returns a grant's raw label (unsanitized: callers rendering
822// it into markdown must sanitize, as this realm's Render does).
823func GrantLabel(id int64) string { return mustGet(id).label }
824
825// Remaining is the AUTHORITY figure: what the grant's terms permit right
826// now, ignoring whether the owner's pool can cover it.
827func Remaining(id int64) int64 {
828	g := mustGet(id)
829	return book.Remaining(g.allowID, runtime.ChainHeight())
830}
831
832// Drawable is the HONEST figure, and the one a delegate should read: the
833// lesser of what the grant authorizes and what the owner's pool holds.
834// This distinction is the realm's own — spendbook bounds permission and
835// says so, never solvency.
836func Drawable(id int64) int64 {
837	g := mustGet(id)
838	authorized := book.Remaining(g.allowID, runtime.ChainHeight())
839	pool := poolOf(g.owner.String())
840	if pool < authorized {
841		return pool
842	}
843	return authorized
844}
845
846// PendingTerms returns a queued loosening amendment and whether there is
847// one. An amendment is queued only when it would widen the live window,
848// and it takes effect at the next window boundary.
849func PendingTerms(id int64) (perPeriod, total, expiresAt int64, pending bool) {
850	a := mustAllowance(mustGet(id))
851	if !a.HasPending {
852		return 0, 0, 0, false
853	}
854	return a.Pending.PerPeriod, a.Pending.Total, a.Pending.ExpiresAt, true
855}
856
857// IsExpired reports whether a grant's authority has lapsed, which is
858// also the condition under which anyone may Reap it.
859func IsExpired(id int64) bool {
860	return mustAllowance(mustGet(id)).IsExpired(runtime.ChainHeight())
861}
862
863// PoolOf returns an owner's unspent pool.
864func PoolOf(addr address) int64 { return poolOf(addr.String()) }
865
866// CreditOf returns a delegate's drawn-but-unclaimed credit.
867func CreditOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
868
869// GrantsOf returns how many live grants an owner currently holds.
870func GrantsOf(addr address) int64 { return ownerOpen(addr.String()) }
871
872// NumGrants returns how many grants have ever been created. Ids are
873// never reused, so this only grows.
874func NumGrants() int64 { return nextID }
875
876// LiveGrants returns how many grants are currently live, from the
877// primitive's own count.
878func LiveGrants() int64 { return int64(book.Count()) }
879
880// TotalPooled returns the P term: unspent owner funds.
881func TotalPooled() int64 { return totalPooled }
882
883// UnclaimedCredit returns the delegates' share of the L term.
884func UnclaimedCredit() int64 { return ledger.UsersTotal() }
885
886// FeesAccrued returns the fee pot: charged, not yet withdrawn.
887func FeesAccrued() int64 { return ledger.FeesAccrued() }
888
889// Liabilities returns P + L — everything this realm owes.
890func Liabilities() int64 { return totalPooled + ledger.Liabilities() }
891
892// Held returns H: the ugnot actually at this realm's address.
893func Held() int64 { return coinio.HeldAt(self, Denom) }
894
895// Surplus returns H - (P + L) — out-of-band coins, sweepable.
896func Surplus() int64 { return Held() - Liabilities() }
897
898// Address returns this realm's own address.
899func Address() address { return self }
900
901// Height returns the current chain height: the clock every window and
902// expiry is measured against, and the one no caller can choose.
903func Height() int64 { return runtime.ChainHeight() }
904
905// --- render ---
906
907// Render shows the realm at "" and a grant page at "<id>". Labels are
908// free text and pass through the ecosystem sanitizer before hitting
909// markdown.
910func Render(path string) string {
911	if path != "" {
912		return renderGrant(path)
913	}
914	now := runtime.ChainHeight()
915	held := Held()
916	liab := Liabilities()
917	conservation := "OK"
918	if held < liab {
919		conservation = "VIOLATED"
920	}
921
922	out := "# Budgets\n\n"
923	out += "Delegated spending in GNOT. Fund a pool, grant a delegate a " +
924		"standing rate-limited authority over it, and they draw within " +
925		"that bound without asking anyone. Revocable immediately; the " +
926		"windows are anchored, so nobody resets a cap by choosing when " +
927		"to call.\n\n"
928	out += "## Terms (fixed at deploy; no admin, no setters)\n\n"
929	out += "- draw fee: " + bps(DrawFeeBps) + " of the gross draw, paid from the owner's budget (hard cap " + bps(MaxFeeBps) + ")\n"
930	out += "- no fee on funding, on withdrawing an unspent pool, or on claiming\n"
931	out += "- minimum funding: " + itoa(MinFunding) + Denom + " per call\n"
932	out += "- minimum per-window cap: " + itoa(MinPerPeriod) + Denom + "\n"
933	out += "- window length: 0 (one lifetime window) or at least " + itoa(MinPeriodBlocks) + " blocks\n"
934	out += "- live grants: " + itoa(LiveGrants()) + " of " + strconv.Itoa(MaxLiveGrants) +
935		" (per owner: " + itoa(MaxGrantsPerOwner) + ")\n"
936	out += "- current height: " + itoa(now) + "\n\n"
937	out += "## Accounting (H == P + L + S)\n\n"
938	out += "- pooled (P): " + itoa(totalPooled) + Denom + "\n"
939	out += "- unclaimed delegate credit: " + itoa(ledger.UsersTotal()) + Denom + "\n"
940	out += "- fee pot: " + itoa(ledger.FeesAccrued()) + Denom + "\n"
941	out += "- held (H): " + itoa(held) + Denom + "\n"
942	out += "- surplus (S): " + itoa(held-liab) + Denom + "\n"
943	out += "- conservation: " + conservation + "\n"
944	out += "- lifetime funded / drawn / claimed / returned: " +
945		itoa(lifetimeFunded) + " / " + itoa(lifetimeDrawn) + " / " +
946		itoa(lifetimeClaimed) + " / " + itoa(lifetimeReturned) + Denom + "\n\n"
947	out += "## Live grants\n\n"
948	if grants.Size() == 0 {
949		out += "None yet. Fund a pool, then Grant a delegate.\n"
950	} else {
951		shown := 0
952		expired := 0
953		grants.ReverseIterate("", "", func(_ string, v any) bool {
954			g := v.(*grant)
955			a, aerr := book.Get(g.allowID)
956			state := "live"
957			if aerr != nil {
958				state = "missing"
959			} else if a.IsExpired(now) {
960				state = "expired (reapable)"
961				expired++
962			}
963			out += "- [#" + itoa(g.id) + "](" + RealmPath + ":" + itoa(g.id) +
964				") [" + state + "] " + sanitize.InlineText(labelOr(g)) +
965				" — drawable " + itoa(Drawable(g.id)) + Denom + "\n"
966			shown++
967			return shown >= MaxRenderRows
968		})
969		if shown < grants.Size() {
970			out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) +
971				" most recent of " + strconv.Itoa(grants.Size()) +
972				" live grants (" + itoa(nextID) +
973				" ever created; revoked and reaped records are removed). " +
974				"Use GrantInfo(id) for any specific grant.\n"
975		}
976		if expired > 0 {
977			out += "\n## Keeper work\n\nAnyone may Reap(id) an expired grant " +
978				"to free its slot and reclaim its storage deposit: " +
979				strconv.Itoa(expired) + " of the grants above are reapable.\n"
980		}
981	}
982	return out
983}
984
985func renderGrant(path string) string {
986	id, err := strconv.ParseInt(path, 10, 64)
987	if err != nil {
988		return "> [!WARNING]\n> invalid grant id\n"
989	}
990	v := grants.Get(padID(id))
991	if v == nil {
992		return "> [!WARNING]\n> unknown grant id (never created, or revoked/reaped)\n"
993	}
994	g := v.(*grant)
995	a, gerr := book.Get(g.allowID)
996	if gerr != nil {
997		// Unreachable: drop() removes the record and the allowance
998		// together, in one call. Rendered rather than panicked so a
999		// corrupted pair is visible instead of breaking the page.
1000		return "> [!CAUTION]\n> grant #" + itoa(g.id) +
1001			" has no backing allowance; this is a bug. Reason: " +
1002			sanitize.InlineText(gerr.Error()) + "\n"
1003	}
1004	now := runtime.ChainHeight()
1005
1006	out := "# Grant #" + itoa(g.id)
1007	if g.label != "" {
1008		out += ": " + sanitize.InlineText(g.label)
1009	}
1010	out += "\n\n"
1011	out += "- owner: `" + g.owner.String() + "`\n"
1012	out += "- delegate: `" + g.spender.String() + "`\n"
1013	terms, pendingGoverns := termsAt(a, now)
1014	out += "- per-window cap: " + itoa(terms.PerPeriod) + Denom + "\n"
1015	if terms.PeriodLength == 0 {
1016		out += "- window: one lifetime window; the cap never refills\n"
1017	} else {
1018		out += "- window: " + itoa(terms.PeriodLength) +
1019			" blocks, anchored at " + itoa(a.Anchor) +
1020			" (now in window " + itoa(windowAt(a, now)) + ")\n"
1021	}
1022	if terms.Total == 0 {
1023		out += "- lifetime cap: none\n"
1024	} else {
1025		out += "- lifetime cap: " + itoa(terms.Total) + Denom +
1026			" (" + itoa(a.SpentTotal) + " used)\n"
1027	}
1028	if terms.ExpiresAt == 0 {
1029		out += "- expiry: none (only the owner can end it)\n"
1030	} else {
1031		out += "- expiry: block " + itoa(terms.ExpiresAt) + "\n"
1032	}
1033	out += "- draw fee: " + bps(g.feeBps) + " (snapshotted at grant time)\n"
1034	out += "- spent this window: " + itoa(spentInWindow(a, now)) + Denom + "\n"
1035	out += "- drawn in total: " + itoa(g.drawn) + Denom + " over " +
1036		itoa(g.draws) + " draws\n"
1037	out += "- created at block: " + itoa(g.createdAt) + "\n"
1038	out += "- current height: " + itoa(now) + "\n\n"
1039
1040	authorized := a.Remaining(now)
1041	pool := poolOf(g.owner.String())
1042	out += "## What can be drawn right now\n\n"
1043	out += "- authorized by the terms: " + itoa(authorized) + Denom + "\n"
1044	out += "- owner's unspent pool: " + itoa(pool) + Denom + "\n"
1045	drawable := authorized
1046	if pool < drawable {
1047		drawable = pool
1048	}
1049	out += "- **drawable: " + itoa(drawable) + Denom + "** (the lesser of the two)\n\n"
1050
1051	switch {
1052	case a.IsExpired(now):
1053		out += "This grant has **expired**. Anyone may Reap(" + itoa(g.id) +
1054			") to free its slot and reclaim its storage deposit.\n"
1055	case drawable == 0 && authorized == 0:
1056		out += "Nothing authorized at this height — the window cap is spent, " +
1057			"the lifetime cap is reached, or the grant has not started.\n"
1058	case drawable == 0:
1059		out += "Authorized but **not funded**: the owner's pool cannot cover " +
1060			"a draw. A budget is authority, not a reservation.\n"
1061	default:
1062		out += "The delegate may Draw(" + itoa(g.id) + ", amount) up to the " +
1063			"drawable figure, then Claim() to receive it.\n"
1064	}
1065	if a.HasPending {
1066		if pendingGoverns {
1067			out += "\n> [!NOTE]\n> A **looser** amendment crossed its window " +
1068				"boundary and NOW GOVERNS; the terms above are the amended " +
1069				"ones. The stored record updates on the next draw or " +
1070				"amendment, which changes nothing about what is enforced.\n"
1071		} else {
1072			out += "\n> [!NOTE]\n> A **looser** amendment is queued and takes " +
1073				"effect at the next window boundary: per-window cap " +
1074				itoa(a.Pending.PerPeriod) + Denom + ", lifetime cap " +
1075				itoa(a.Pending.Total) + ", expiry " + itoa(a.Pending.ExpiresAt) +
1076				" (0 means none). Until then the terms above are what govern.\n"
1077		}
1078	}
1079	return out
1080}
1081
1082// --- internals ---
1083
1084func mustGet(id int64) *grant {
1085	v := grants.Get(padID(id))
1086	if v == nil {
1087		panic("unknown grant id")
1088	}
1089	return v.(*grant)
1090}
1091
1092// mustAllowance fetches the spendbook allowance behind a grant. The pair
1093// is created and removed together, so a missing allowance is a bug in
1094// this realm rather than a user error, and it panics rather than
1095// reporting a zero value that a reader would take for "no authority".
1096func mustAllowance(g *grant) spendbook.Allowance {
1097	a, err := book.Get(g.allowID)
1098	if err != nil {
1099		panic(err)
1100	}
1101	return a
1102}
1103
1104// windowAt returns the anchored window index containing now, and
1105// spentInWindow what that window has spent.
1106//
1107// These duplicate a line of spendbook, and the duplication is deliberate,
1108// bounded, and worth justifying because the primitive's own at() comment
1109// warns against exactly this. What is NOT reimplemented is the part that
1110// warning is about: which terms are in force after a boundary, and
1111// therefore every authority figure this realm reports — Remaining and
1112// Drawable both call through to the primitive, and nothing here second-
1113// guesses them.
1114//
1115// What is copied is pure index arithmetic over three EXPORTED fields plus
1116// the already-documented fact that the in-window spend RESETS rather than
1117// carries. The alternative is worse than the duplication: Allowance's
1118// WindowIndex and SpentWindow are the last-WRITTEN pair, so between a
1119// boundary and the next draw they describe a window the chain has left, and
1120// a Render publishing them would print "spent this window: 100000"
1121// immediately above "authorized by the terms: 100000".
1122//
1123// The two are pinned to the primitive by TestWindowRefillsAtAnchoredBoundary
1124// rather than by inspection: it draws after crossing a boundary and asserts
1125// spentWindow equals that draw, which can only hold if windowAt agreed with
1126// spendbook's own index at the moment of the write.
1127// termsAt mirrors spendbook's own at() resolution for DISPLAY: a queued
1128// loosening GOVERNS enforcement from the first boundary after it was
1129// queued, whether or not any write has observed the roll. The page must
1130// derive its terms exactly as the gate does, or it understates the
1131// owner's exposure in the window where that understatement matters most
1132// (audit 2026-11-04, N1 -- the realm fixed this staleness class for
1133// SpentWindow/WindowIndex via windowAt/spentInWindow and left the Terms
1134// display uncovered). Second return reports whether the pending terms
1135// are the governing ones.
1136func termsAt(a spendbook.Allowance, now int64) (spendbook.Terms, bool) {
1137	if a.HasPending && windowAt(a, now) != a.WindowIndex {
1138		return a.Pending, true
1139	}
1140	return a.Terms, false
1141}
1142
1143func windowAt(a spendbook.Allowance, now int64) int64 {
1144	if a.Terms.PeriodLength == 0 || now < a.Anchor {
1145		return 0
1146	}
1147	return (now - a.Anchor) / a.Terms.PeriodLength
1148}
1149
1150func spentInWindow(a spendbook.Allowance, now int64) int64 {
1151	if windowAt(a, now) != a.WindowIndex {
1152		return 0
1153	}
1154	return a.SpentWindow
1155}
1156
1157// drop removes a grant record and decrements its owner's live count. The
1158// allowance itself is removed by the spendbook call that precedes every
1159// caller of this helper.
1160func drop(g *grant) {
1161	grants.Remove(padID(g.id))
1162	decOwnerOpen(g.owner.String())
1163}
1164
1165func labelOr(g *grant) string {
1166	if g.label == "" {
1167		return "(no label)"
1168	}
1169	return g.label
1170}
1171
1172func poolOf(key string) int64 {
1173	v := pools.Get(key)
1174	if v == nil {
1175		return 0
1176	}
1177	return v.(int64)
1178}
1179
1180func setPool(key string, n int64) {
1181	if n <= 0 {
1182		pools.Remove(key)
1183		return
1184	}
1185	pools.Set(key, n)
1186}
1187
1188func ownerOpen(key string) int64 {
1189	v := openByOwner.Get(key)
1190	if v == nil {
1191		return 0
1192	}
1193	return v.(int64)
1194}
1195
1196func setOwnerOpen(key string, n int64) {
1197	if n <= 0 {
1198		openByOwner.Remove(key)
1199		return
1200	}
1201	openByOwner.Set(key, n)
1202}
1203
1204func decOwnerOpen(key string) { setOwnerOpen(key, ownerOpen(key)-1) }
1205
1206func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1207
1208func boolstr(b bool) string {
1209	if b {
1210		return "true"
1211	}
1212	return "false"
1213}
1214
1215// bps renders a basis-point figure as a human percentage, exactly.
1216func bps(n int64) string {
1217	whole := n / 100
1218	frac := n % 100
1219	if frac == 0 {
1220		return itoa(whole) + "%"
1221	}
1222	if frac%10 == 0 {
1223		return itoa(whole) + "." + itoa(frac/10) + "%"
1224	}
1225	pad := ""
1226	if frac < 10 {
1227		pad = "0"
1228	}
1229	return itoa(whole) + "." + pad + itoa(frac) + "%"
1230}
1231
1232// padID encodes an id so avl's lexical order matches numeric order.
1233func padID(id int64) string {
1234	s := strconv.FormatInt(id, 10)
1235	const width = 20
1236	if len(s) >= width {
1237		return s
1238	}
1239	return zeros[:width-len(s)] + s
1240}
1241
1242const zeros = "00000000000000000000"
1243
1244// checkedAdd returns a+b and reports whether the addition did not
1245// overflow int64.
1246func checkedAdd(a, b int64) (int64, bool) {
1247	sum := a + b
1248	if (b > 0 && sum < a) || (b < 0 && sum > a) {
1249		return 0, false
1250	}
1251	return sum, true
1252}
1253

Raw Package Data

Raw JSON data