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.

budgets.gno

47.89 Kb · 1252 lines
   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}